├── .eslintrc.cjs ├── .gitignore ├── LICENSE ├── README.md ├── dist ├── game.zip └── index.html ├── index.html ├── package-lock.json ├── package.json ├── plugins └── vite-js13k.js ├── screenshot-bigx1.png ├── screenshot-bigx2.png ├── screenshot-smallx1.png ├── screenshot-smallx2.png ├── src ├── animal.js ├── audio.js ├── cell.js ├── colors.js ├── create-element.js ├── demo-colors.js ├── farm.js ├── find-route.js ├── fish-emoji.js ├── fish-farm.js ├── fish.js ├── gameover.js ├── goat-emoji.js ├── goat-farm.js ├── goat.js ├── grid-toggle.js ├── grid.js ├── hull.js ├── inventory.js ├── keyboard.js ├── layers.js ├── main.js ├── menu-background.js ├── menu.js ├── modified-kontra │ ├── game-loop.js │ ├── game-object.js │ └── updatable.js ├── ox-emoji.js ├── ox-farm.js ├── ox.js ├── path.js ├── person.js ├── pointer.js ├── pond.js ├── remove-path.js ├── shuffle.js ├── spawning.js ├── svg-utils.js ├── svg.js ├── tree.js ├── ui.js ├── vector.js ├── weighted-random.js └── yurt.js └── vite.config.js /.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 | 'no-continue': 'off', 26 | 'no-new': 'off', // GameObjects assign themselves to appropriate lists so are not thrown away 27 | 'no-plusplus': 'off', 28 | 'no-return-assign': 'off', 29 | 'import/prefer-default-export': 'off', 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist/minified.js 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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) [Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API) implementation, 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 |

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 | 59 | ### Known issues 60 | 61 | - There is a weird flashing that can happen during the gameover transition on Chrome on Android. 62 | - There's occasionally a case of disappearing paths on iOS if the game is navigated away from. 63 | - On small screens, drawing paths is a little fiddly! 64 | -------------------------------------------------------------------------------- /dist/game.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/js13kGames/tiny-yurts/484987aa10a187a1a94cc1f8ca042dca864e3153/dist/game.zip -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | .replace(/target/g, '_target') 127 | .replace(/maxDistance/g, '_maxDistance') 128 | .replace(/baseLayer/g, '_baseLayer') 129 | // Replace const with let declartion 130 | .replaceAll('const ', 'let ') 131 | // Replace all strict equality comparison with abstract equality comparison 132 | .replaceAll('===', '==') 133 | .replaceAll('!==', '!=') 134 | // Fix accidentally "minified" highscore text 135 | .replaceAll('Highscore:', 'Highscore: ') 136 | // .replace(/update/g, '_update') 137 | 138 | return replaced; 139 | } 140 | 141 | export function viteJs13kPre() { 142 | return { 143 | enforce: 'pre', 144 | transform(src, id) { 145 | if (fileRegex.test(id)) { 146 | return { 147 | code: customReplacement(src), 148 | map: null 149 | } 150 | } 151 | } 152 | } 153 | } 154 | 155 | export function viteJs13k() { 156 | return { 157 | enforce: "post", 158 | generateBundle: async (_, bundle) => { 159 | const jsExtensionTest = /\.[mc]?js$/; 160 | const htmlFiles = Object.keys(bundle).filter((i) => i.endsWith(".html")); 161 | const cssAssets = Object.keys(bundle).filter((i) => i.endsWith(".css")); 162 | const jsAssets = Object.keys(bundle).filter((i) => jsExtensionTest.test(i)); 163 | const bundlesToDelete = []; 164 | for (const name of htmlFiles) { 165 | const htmlChunk = bundle[name]; 166 | let replacedHtml = htmlChunk.source; 167 | 168 | for (const jsName of jsAssets) { 169 | const jsChunk = bundle[jsName]; 170 | if (jsChunk.code != null) { 171 | bundlesToDelete.push(jsName); 172 | replacedHtml = await replaceScript(replacedHtml, jsChunk.fileName, jsChunk.code); 173 | } 174 | } 175 | 176 | for (const cssName of cssAssets) { 177 | const cssChunk = bundle[cssName]; 178 | bundlesToDelete.push(cssName); 179 | replacedHtml = replaceCss(replacedHtml, cssChunk.fileName, cssChunk.source); 180 | } 181 | 182 | replacedHtml = replaceHtml(replacedHtml); 183 | htmlChunk.source = replacedHtml; 184 | await zip(replacedHtml); 185 | } 186 | for (const name of bundlesToDelete) { 187 | delete bundle[name]; 188 | } 189 | }, 190 | closeBundle: () => { 191 | console.log(`\nZip size: ${fs.statSync('dist/game.zip').size}B`); 192 | 193 | execFile(advzip, [ 194 | '--recompress', 195 | '--shrink-insane', 196 | '--iter=8000', 197 | 'dist/game.zip' 198 | ], (err) => { 199 | console.log(`\nZip size: ${fs.statSync('dist/game.zip').size}B (advzip)`); 200 | }); 201 | }, 202 | }; 203 | } 204 | -------------------------------------------------------------------------------- /screenshot-bigx1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/js13kGames/tiny-yurts/484987aa10a187a1a94cc1f8ca042dca864e3153/screenshot-bigx1.png -------------------------------------------------------------------------------- /screenshot-bigx2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/js13kGames/tiny-yurts/484987aa10a187a1a94cc1f8ca042dca864e3153/screenshot-bigx2.png -------------------------------------------------------------------------------- /screenshot-smallx1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/js13kGames/tiny-yurts/484987aa10a187a1a94cc1f8ca042dca864e3153/screenshot-smallx1.png -------------------------------------------------------------------------------- /screenshot-smallx2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/js13kGames/tiny-yurts/484987aa10a187a1a94cc1f8ca042dca864e3153/screenshot-smallx2.png -------------------------------------------------------------------------------- /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/audio.js: -------------------------------------------------------------------------------- 1 | import { colors } from "./colors"; 2 | 3 | // This must only be called on user interaction. So probably on pressing a 4 | // main menu button? But we don't want to re-do it er ever as well hrm 5 | let audioContext; 6 | 7 | // Sample rate in Hz. Could be fetched with audioContext.sampleRate, but writing 8 | // out just the number will minify slightly better 9 | const sampleRate = 44100; 10 | 11 | export const soundSetings = { 12 | on: localStorage.getItem('Tiny Yurtss') === 'false' ? false : true, 13 | }; 14 | 15 | export const initAudio = () => { 16 | if (!audioContext) { 17 | audioContext = new AudioContext(); 18 | } 19 | 20 | // Do we need to return it?... 21 | return audioContext; 22 | }; 23 | 24 | export const playSound = ( 25 | frequencyIndex, 26 | noteLength = 2, 27 | playbackRate = 1, 28 | // Makes the note not go on for as long, like it's pinging a tighter string 29 | pingyness = 1, 30 | volume = 1, 31 | // Most sound below this frequency (Hz) goes through 32 | lowpassFrequency = 10000, 33 | // Most sound above this frequency (Hz) goes through 34 | highpassFrequency = 100, 35 | noise = () => (2 * Math.random() - 1), 36 | ) => { 37 | if (!soundSetings.on) return; 38 | 39 | // Magic maths to get an index to line up with musical notes 40 | const frequency = 130.81 * 1.0595 ** frequencyIndex; 41 | const bufferData = []; 42 | const v = []; 43 | let p = 0; 44 | const period = sampleRate / frequency; 45 | let reset; 46 | 47 | const w = () => { 48 | reset = false; 49 | return v.length <= 1 + Math.floor(period) 50 | ? (v.push(noise()), v.at(-1)) 51 | : ( 52 | v[p] = ( 53 | v[p >= v.length - 1 ? 0 : p + 1] * 0.5 + v[p] * (0.5 - (pingyness / 1000)) 54 | // v[p >= v.length - 1 ? 0 : p + 1] 55 | ), 56 | p >= Math.floor(period) && ( 57 | reset = true, v[p + 1] = (v[0] * 0.5 + v[p + 1] * 0.5) 58 | ), 59 | p = reset ? 0 : p + 1, 60 | v[p] 61 | ); 62 | }; 63 | 64 | for ( 65 | let i = 0; 66 | i < sampleRate * noteLength; 67 | i++ 68 | ) { 69 | bufferData[i] = i < 88 70 | ? i / 88 * w() 71 | : (1 - (i - 88) / (sampleRate * noteLength)) * w(); 72 | } 73 | 74 | const buffer = audioContext.createBuffer(1, sampleRate * noteLength, sampleRate); 75 | buffer.getChannelData(0).set(bufferData); 76 | 77 | const source = audioContext.createBufferSource(); 78 | source.buffer = buffer; 79 | source.playbackRate.value = playbackRate; 80 | 81 | const lowpassNode = audioContext.createBiquadFilter(); 82 | lowpassNode.type = 'lowpass'; 83 | lowpassNode.frequency.value = lowpassFrequency; 84 | 85 | // Two low pass filters for more aggressive filtering, 86 | // without using many more bytes as they're identical 87 | const lowpassNode2 = audioContext.createBiquadFilter(); 88 | lowpassNode2.type = 'lowpass'; 89 | lowpassNode2.frequency.value = lowpassFrequency; 90 | 91 | const highpassNode = audioContext.createBiquadFilter(); 92 | highpassNode.type = 'highpass'; 93 | highpassNode.frequency.value = highpassFrequency; 94 | 95 | const volumeNode = audioContext.createGain(); 96 | volumeNode.gain.value = volume; 97 | 98 | source.connect(lowpassNode); 99 | lowpassNode.connect(lowpassNode2); 100 | lowpassNode2.connect(highpassNode); 101 | highpassNode.connect(volumeNode); 102 | volumeNode.connect(audioContext.destination); 103 | source.start(); 104 | }; 105 | 106 | // Ox & Goat tunes are based off Ravel's Ma mère l'oye (1910) III. Laideronnette, impératrice des pagodes: 107 | // https://en.wikipedia.org/wiki/File:Ravel_Ma_Mere_l%27Oye_Laideronnette_Imperatricedes_Pagodes_m.9-13.png 108 | 109 | // [ frequencyIndex, noteLength, playbackRate, pingyness, volume, lowpass, highpass ] 110 | const warnNotes = { 111 | [colors.ox]: { 112 | currentIndex: 0, 113 | notes: [ 114 | [18, 0.5, 0.5, 30, 0.2, 1800, 200], // F# 115 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 116 | [13, 0.5, 0.5, 30, 0.2, 1800, 200], // C# 117 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 118 | 119 | [10, 0.5, 0.5, 30, 0.2, 1800, 200], // A# 120 | [18, 0.5, 0.5, 30, 0.2, 1800, 200], // F# 121 | [13, 0.5, 0.5, 30, 0.2, 1800, 200], // C# 122 | 123 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 124 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 125 | 126 | [18, 0.5, 0.5, 30, 0.2, 1800, 200], // F# 127 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 128 | [13, 0.5, 0.5, 30, 0.2, 1800, 200], // C# 129 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 130 | 131 | [10, 0.5, 0.5, 30, 0.2, 1800, 200], // A# 132 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 133 | [18, 0.5, 0.5, 30, 0.2, 1800, 200], // F# 134 | [13, 0.5, 0.5, 30, 0.2, 1800, 200], // C# 135 | 136 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 137 | [18, 0.5, 0.5, 30, 0.2, 1800, 200], // F# 138 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 139 | 140 | [13, 0.5, 0.5, 30, 0.2, 1800, 200], // C# 141 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 142 | [10, 0.5, 0.5, 30, 0.2, 1800, 200], // A# 143 | [13, 0.5, 0.5, 30, 0.2, 1800, 200], // C# 144 | 145 | [ 8, 0.5, 0.5, 30, 0.2, 1800, 200], // G# (first one) 146 | [10, 0.5, 0.5, 30, 0.2, 1800, 200], // A# 147 | [ 5, 0.5, 0.5, 30, 0.2, 1800, 200], // E# (F) 148 | [ 8, 0.5, 0.5, 30, 0.2, 1800, 200], // G# 149 | ], 150 | }, 151 | [colors.goat]: { 152 | currentIndex: 0, 153 | notes: [ 154 | [30, 1, 1, 1, 0.2, 3000, 1000], // F# 155 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 0.995 is annoying but repeated it might not be too bad 156 | [25, 1, 1, 1, 0.2, 3000, 1000], // C# 157 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 158 | 159 | [22, 1, 1, 1, 0.2, 3000, 1000], // A# 160 | [30, 1, 1, 1, 0.2, 3000, 1000], // F# 161 | [25, 1, 1, 1, 0.2, 3000, 1000], // C# 162 | 163 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 164 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 165 | 166 | [30, 1, 1, 1, 0.2, 3000, 1000], // F# 167 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 168 | [25, 1, 1, 1, 0.2, 3000, 1000], // C# 169 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 170 | 171 | [22, 1, 1, 1, 0.2, 3000, 1000], // A# 172 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 173 | [30, 1, 1, 1, 0.2, 3000, 1000], // F# 174 | [25, 1, 1, 1, 0.2, 3000, 1000], // C# 175 | 176 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 177 | [30, 1, 1, 1, 0.2, 3000, 1000], // F# 178 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 179 | 180 | [25, 1, 1, 1, 0.2, 3000, 1000], // C# 181 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 182 | [22, 1, 1, 1, 0.2, 3000, 1000], // A# 183 | [25, 1, 1, 1, 0.2, 3000, 1000], // C# 184 | 185 | [20, 1, 1, 1, 0.2, 3000, 1000], // G# (first one) 186 | [22, 1, 1, 1, 0.2, 3000, 1000], // A# 187 | [17, 1, 1, 1, 0.2, 3000, 1000], // E# (i.e. F. Music is weird) 188 | [20, 1, 1, 1, 0.2, 3000, 1000], // G# 189 | ], 190 | }, 191 | [colors.fish]: { 192 | currentIndex: 0, 193 | notes: [ 194 | [70, 0.1, 0.05, 900, 1, 1000, 200], 195 | [73, 0.1, 0.05, 900, 1, 1000, 200], 196 | [68, 0.1, 0.05, 900, 1, 1000, 200], 197 | [70, 0.1, 0.05, 900, 1, 1000, 200], 198 | [70, 0.1, 0.05, 900, 1, 1000, 200], 199 | [73, 0.1, 0.05, 900, 1, 1000, 200], 200 | [68, 0.1, 0.05, 900, 1, 1000, 200], 201 | ], 202 | }, 203 | }; 204 | 205 | export const playPathPlacementNote = () => { 206 | if (audioContext) { 207 | // frequencyIndex, noteLength, playbackRate, pingyness, volume, lowpass, highpass 208 | playSound(1, 0.5, 1, 0, 1, 1000, 300, () => 2); 209 | } 210 | }; 211 | 212 | export const playPathDeleteNote = () => { 213 | if (audioContext) { 214 | // frequencyIndex, noteLength, playbackRate, pingyness, volume, lowpass, highpass 215 | playSound(1, 0.5, 1, 0, 6, 800, 1500, () => 2); 216 | } 217 | }; 218 | 219 | export const playTreeDeleteNote = () => { 220 | if (audioContext) { 221 | playSound(10, 0.1, 1, 1000, 0.2, 1500, 500, () => 2); 222 | } 223 | }; 224 | 225 | export const playYurtSpawnNote = () => { 226 | if (audioContext) { 227 | playSound(39, 0.1, 0.25, 10, 0.2, 1000, 100); // E# (i.e. F. Music is weird) 228 | } 229 | }; 230 | 231 | export const playOutOfPathsNote = () => { 232 | if (audioContext) { 233 | // frequencyIndex, noteLength, playbackRate, pingyness, volume, lowpass, highpass 234 | // playSound(18, 0.5, 0.25, 30, 0.2, 1800, 200); 235 | setTimeout(() => playSound(8, 0.5, 0.5, 40, 0.1, 1000, 100), 100); 236 | setTimeout(() => playSound(5, 0.5, 0.5, 20, 0.1, 1000, 100), 250); 237 | } 238 | }; 239 | 240 | export const playWarnNote = (animalType) => { 241 | if (audioContext) { 242 | // console.log(warnNotes[animalType]); 243 | const noteInfo = warnNotes[animalType].notes[warnNotes[animalType].currentIndex]; 244 | warnNotes[animalType].currentIndex = (warnNotes[animalType].currentIndex + 1) % warnNotes[animalType].notes.length; 245 | playSound(...noteInfo); 246 | // const { currentIndex, notes } = warnNotes[animalType]; 247 | // playSound(notes[currentIndex++]); 248 | } 249 | }; 250 | -------------------------------------------------------------------------------- /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 | return false; // TODO: Maybe remove or swap to void to save space 66 | }; 67 | -------------------------------------------------------------------------------- /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/create-element.js: -------------------------------------------------------------------------------- 1 | export const createElement = (tag = 'div') => document.createElement(tag); 2 | -------------------------------------------------------------------------------- /src/demo-colors.js: -------------------------------------------------------------------------------- 1 | import { colors } from './colors'; 2 | 3 | export const demoColors = () => { 4 | const colorTestContainer = createElement('svg'); 5 | colorTestContainer.style.cssText = (`position:absolute;left:8px;bottom:32px;display:grid;gap:8px;`); 6 | document.body.append(colorTestContainer); 7 | Object.entries(colors).forEach(([name, value]) => { 8 | const dot = createElement(); 9 | dot.style.cssText = `display:block;width:16px;height:16px;border-radius:50%;overflow:visible;`; 10 | dot.innerHTML = `
${value}: ${name}
`; 11 | dot.style.background = value; 12 | colorTestContainer.append(dot); 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /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 thisPersonsRoute = 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 = thisPersonsRoute; 141 | closestPerson = atHomePeopleOfSameType[j]; 142 | } 143 | 144 | // If this persons route has fewer nodes, it's probably faster. 145 | if (thisPersonsRoute && thisPersonsRoute.length < bestRoute.length) { 146 | bestRoute = thisPersonsRoute; 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 (thisPersonsRoute && thisPersonsRoute.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 = thisPersonsRoute.reduce((acc, curr) => acc + (curr.distance ?? 0), 0); 158 | 159 | if (thisDistance < bestDistance) { 160 | bestRoute = thisPersonsRoute; 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/find-route.js: -------------------------------------------------------------------------------- 1 | import { paths } from './path'; 2 | import { gridWidth, gridHeight } from './svg'; 3 | 4 | let gridData = []; 5 | 6 | // TODO: Prefer straight x/y over diagonals because they are actually shorter distance 7 | 8 | const getGridData = () => { 9 | // This should be cached somewhere, maybe a second after a grid piece is placed 10 | const gridData = []; 11 | 12 | for (let x = 0; x < gridWidth; x++) { 13 | for (let y = 0; y < gridHeight; y++) { 14 | gridData.push({ x, y, neighbors: [] }); 15 | } 16 | } 17 | 18 | paths.forEach((path) => { 19 | // TODO: See why or how this fails? 20 | if (gridData) { 21 | gridData 22 | .find((d) => d.x === path.points[0].x && d.y === path.points[0].y) 23 | .neighbors 24 | .push({ x: path.points[1].x, y: path.points[1].y }); 25 | 26 | gridData 27 | .find((d) => d.x === path.points[1].x && d.y === path.points[1].y) 28 | .neighbors 29 | .push({ x: path.points[0].x, y: path.points[0].y }); 30 | } 31 | }); 32 | 33 | return gridData; 34 | }; 35 | 36 | export const updateGridData = () => { 37 | gridData = getGridData(); 38 | }; 39 | 40 | const breadthFirstSearch = (gridData, from, to) => { 41 | const queue = [{ node: from, path: [] }]; 42 | const visited = []; 43 | 44 | // console.log('from'); 45 | // console.log(from); 46 | 47 | // console.log('QUEUE:'); 48 | // console.log(JSON.stringify(queue)); 49 | 50 | while (queue.length) { 51 | const { node, path } = queue.shift(); 52 | 53 | if (node === undefined) { 54 | // Not sure how nodes could be undefined but fine? 55 | return false; // TODO: See if returning nothing (void) saves space 56 | } 57 | 58 | // Are we at the end? 59 | if (to.find((t) => node.x === t.x && node.y === t.y)) { 60 | return path.concat(node); 61 | } 62 | 63 | const hasVisited = visited 64 | .some((visitedNode) => visitedNode.x === node.x && visitedNode.y === node.y); 65 | 66 | if (!hasVisited) { 67 | visited.push(node); 68 | 69 | const verticalHorizontalNeighbors = []; 70 | const diagonalNeighbors = []; 71 | 72 | node.neighbors.forEach((neighbor) => { 73 | if (Math.abs(neighbor.x - node.x) === 1 && neighbor.y === node.y) { 74 | verticalHorizontalNeighbors.push(neighbor); 75 | } else if (Math.abs(neighbor.y - node.y) === 1 && neighbor.x === node.x) { 76 | verticalHorizontalNeighbors.push(neighbor); 77 | } else { 78 | diagonalNeighbors.push(neighbor); 79 | } 80 | }); 81 | 82 | verticalHorizontalNeighbors.forEach((neighbor) => { 83 | const hasVisitedNeighbor = visited.some( 84 | (visitedNode) => visitedNode.x === neighbor.x && visitedNode.y === neighbor.y, 85 | ); 86 | 87 | if (!hasVisitedNeighbor) { 88 | queue.push({ 89 | node: gridData.find((c) => c.x === neighbor.x && c.y === neighbor.y), 90 | path: path.concat({ 91 | ...node, 92 | distance: 1, 93 | }), 94 | }); 95 | } 96 | }); 97 | 98 | diagonalNeighbors.forEach((neighbor) => { 99 | const hasVisitedNeighbor = visited.some( 100 | (visitedNode) => visitedNode.x === neighbor.x && visitedNode.y === neighbor.y, 101 | ); 102 | 103 | if (!hasVisitedNeighbor) { 104 | queue.push({ 105 | node: gridData.find((c) => c.x === neighbor.x && c.y === neighbor.y), 106 | path: path.concat({ 107 | ...node, 108 | distance: 1.41, // Approx Math.sqrt(2) 109 | }), 110 | }); 111 | } 112 | }); 113 | } 114 | } 115 | 116 | return null; // Can't get there at all! 117 | }; 118 | 119 | export const findRoute = ({ from, to }) => { 120 | // const gridData = gridDatagetGridData(); 121 | 122 | // Convert from and to to actual grid nodes 123 | const fromNode = gridData.find((c) => c.x === from.x && c.y === from.y); 124 | const toNodes = gridData.filter((c) => to.find((f) => c.x === f.x && c.y === f.y)); 125 | 126 | // console.log('fromNode'); 127 | // console.log(fromNode); 128 | 129 | // console.log('toNodes:'); 130 | // console.log(toNodes); 131 | 132 | return breadthFirstSearch( 133 | gridData, 134 | fromNode, 135 | toNodes, 136 | ); 137 | }; 138 | -------------------------------------------------------------------------------- /src/fish-emoji.js: -------------------------------------------------------------------------------- 1 | import { createSvgElement } from './svg-utils'; 2 | import { colors } from './colors'; 3 | 4 | export const emojiFish = () => { 5 | const emojiFish = createSvgElement(); 6 | emojiFish.setAttribute('viewBox', '0 0 20 20'); 7 | emojiFish.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 | emojiFish.append(fins, body, eye); 23 | 24 | return emojiFish; 25 | }; 26 | -------------------------------------------------------------------------------- /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/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/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 { scoreCounters, uiContainer, gridToggleButton, soundToggleButton, gridRedToggleButton, gridRedToggleTooltip, gridToggleTooltip } from './ui'; 12 | import { createElement } from './create-element'; 13 | 14 | const gameoverWrapper = createElement(); 15 | const gameoverHeader = createElement(); 16 | const gameoverText1 = createElement(); 17 | const gameoverText2 = createElement(); 18 | const gameoverText3 = createElement(); 19 | const gameoverButtons = createElement(); 20 | const restartButtonWrapper = createElement(); 21 | const restartButton = createElement('button'); 22 | const menuButtonWrapper = createElement(); 23 | const menuButton = createElement('button'); 24 | const oxEmojiWrapper = createElement(); 25 | const oxEmoji = emojiOx(); 26 | const goatEmojiWrapper = createElement(); 27 | const goatEmoji = emojiGoat(); 28 | const fishEmojiWrapper = createElement(); 29 | const fishEmoji = emojiFish(); 30 | const scoreWrapper = createElement(); 31 | export const toggleGameoverlayButton = createElement('button'); 32 | 33 | export const initGameover = (startNewGame, gameoverToMenu, toggleGameoverlay) => { 34 | gameoverWrapper.style.cssText = ` 35 | position: absolute; 36 | inset: 0; 37 | padding: 10vmin; 38 | display: flex; 39 | flex-direction: column; 40 | `; 41 | gameoverWrapper.style.pointerEvents = 'none'; 42 | gameoverWrapper.style.opacity = 0; 43 | 44 | gameoverHeader.style.cssText = `font-size: 72px; opacity: 0`; 45 | gameoverHeader.innerText = 'Game Over'; 46 | 47 | gameoverText1.style.cssText = `margin-top: 48px; font-size: 24px; opacity:0`; 48 | gameoverText1.innerText = 'Too few people could tend to this farm in time.'; 49 | 50 | gameoverText2.style.cssText = `margin-top: 16px; font-size: 24px; opacity: 0`; 51 | 52 | // 24px margin-top counteracts the underline in gameoverText2 53 | gameoverText3.style.cssText = ` 54 | display: flex; 55 | flex-wrap: wrap; 56 | gap: 4px; 57 | margin-top: 24px; 58 | font-size: 24px; 59 | `; 60 | gameoverText3.style.opacity = 0; 61 | if (document.body.scrollHeight < 500) { 62 | gameoverText3.style.position = 'absolute'; 63 | gameoverText3.style.bottom = '10vmin'; 64 | gameoverText3.style.right = '10vmin'; 65 | } else { 66 | gameoverText3.style.position = ''; 67 | gameoverText3.style.bottom = ''; 68 | gameoverText3.style.right = ''; 69 | } 70 | addEventListener('resize', () => { 71 | if (document.body.scrollHeight < 500) { 72 | gameoverText3.style.position = 'absolute'; 73 | gameoverText3.style.bottom = '10vmin'; 74 | gameoverText3.style.right = '10vmin'; 75 | } else { 76 | gameoverText3.style.position = ''; 77 | gameoverText3.style.bottom = ''; 78 | gameoverText3.style.right = ''; 79 | } 80 | }); 81 | 82 | oxEmojiWrapper.style.cssText = `display:inline-flex;padding:6px 12px;line-height:24px;color:#fff;border-radius:64px;background:${colors.ui}`; 83 | goatEmojiWrapper.style.cssText = `display:inline-flex;padding:6px 12px;line-height:24px;color:#fff;border-radius:64px;background:${colors.ui}`; 84 | fishEmojiWrapper.style.cssText = `display:inline-flex;padding:6px 12px;line-height:24px;color:#fff;border-radius:64px;background:${colors.ui}`; 85 | scoreWrapper.style.cssText = `display:inline-flex;padding:6px 12px;line-height:24px;color:#fff;border-radius:64px;background:${colors.ui}`; 86 | oxEmoji.style.width = '24px'; 87 | oxEmoji.style.height = '24px'; 88 | goatEmoji.style.width = '24px'; 89 | goatEmoji.style.height = '24px'; 90 | fishEmoji.style.width = '24px'; 91 | fishEmoji.style.height = '24px'; 92 | 93 | menuButtonWrapper.style.opacity = 0; 94 | restartButtonWrapper.style.opacity = 0; 95 | menuButtonWrapper.append(menuButton); 96 | restartButtonWrapper.append(restartButton); 97 | restartButton.innerText = 'Restart'; 98 | menuButton.innerText = 'Menu'; 99 | 100 | restartButton.addEventListener('click', startNewGame); 101 | 102 | menuButton.addEventListener('click', gameoverToMenu); 103 | 104 | gameoverButtons.append(restartButtonWrapper, menuButtonWrapper); 105 | gameoverButtons.style.cssText = `gap: 16px; margin-top: 48px;`; 106 | if (document.body.scrollHeight < 500) { 107 | gameoverButtons.style.display = 'flex'; 108 | gameoverButtons.style.position = 'absolute'; 109 | gameoverButtons.style.bottom = '10vmin'; 110 | gameoverButtons.style.left = '10vmin'; 111 | } else { 112 | gameoverButtons.style.display = 'grid'; 113 | gameoverButtons.style.position = ''; 114 | gameoverButtons.style.bottom = ''; 115 | gameoverButtons.style.left = ''; 116 | } 117 | addEventListener('resize', () => { 118 | if (document.body.scrollHeight < 500) { 119 | gameoverButtons.style.display = 'flex'; 120 | gameoverButtons.style.position = 'absolute'; 121 | gameoverButtons.style.bottom = '10vmin'; 122 | gameoverButtons.style.left = '10vmin'; 123 | } else { 124 | gameoverButtons.style.display = 'grid'; 125 | gameoverButtons.style.position = ''; 126 | gameoverButtons.style.bottom = ''; 127 | gameoverButtons.style.left = ''; 128 | } 129 | }); 130 | 131 | toggleGameoverlayButton.style.cssText = `position: absolute; top: 10vmin; right: 10vmin`; 132 | toggleGameoverlayButton.style.pointerEvents = 'none'; 133 | toggleGameoverlayButton.style.opacity = 0; 134 | toggleGameoverlayButton.innerText = 'Overlay On/Off'; 135 | toggleGameoverlayButton.addEventListener('click', toggleGameoverlay); 136 | 137 | gameoverWrapper.append( 138 | gameoverHeader, 139 | gameoverText1, 140 | gameoverText2, 141 | gameoverText3, 142 | gameoverButtons, 143 | ); 144 | 145 | document.body.append(gameoverWrapper, toggleGameoverlayButton); 146 | }; 147 | 148 | export const showGameover = () => { 149 | const score = yurts.length * 2 + animals.length; 150 | uiContainer.style.zIndex = ''; 151 | 152 | if (score > localStorage.getItem('Tiny Yurts')) { 153 | localStorage.setItem('Tiny Yurts', score); 154 | } 155 | 156 | menuBackground.style.clipPath = `polygon(0 0, 100% 0, 100% 100%, 0 100%)`; 157 | menuBackground.style.transition = `opacity 2s 1s`; 158 | gameoverHeader.style.transition = `opacity .5s 2s`; 159 | gameoverText1.style.transition = `opacity .5s 2s`; 160 | gameoverText2.style.transition = `opacity .5s 2s`; 161 | gameoverText3.style.transition = `opacity .5s 2s`; 162 | restartButtonWrapper.style.transition = `opacity .5s 2.5s`; 163 | menuButtonWrapper.style.transition = `opacity .5s 3s`; 164 | toggleGameoverlayButton.style.transition = `all .2s, opacity .5s 3.5s`; 165 | 166 | oxEmojiWrapper.innerHTML = ''; 167 | oxEmojiWrapper.append(oxEmoji, `×${oxen.length}`); 168 | goatEmojiWrapper.innerHTML = ''; 169 | goatEmojiWrapper.append(goatEmoji, `×${goats.length}`); 170 | fishEmojiWrapper.innerHTML = ''; 171 | fishEmojiWrapper.append(fishEmoji, `×${fishes.length}`); 172 | scoreWrapper.innerHTML = `Score:${score}`; 173 | 174 | const peopleCount = createElement('u'); 175 | peopleCount.innerText = `${yurts.length * 2} settlers`; 176 | 177 | const animalsCount = createElement('u'); 178 | animalsCount.innerText = `${animals.length} animals`; 179 | 180 | gameoverText2.innerHTML = ''; 181 | gameoverText2.append(peopleCount, ' and ', animalsCount, ' lived in your camp.'); 182 | 183 | gameoverText3.innerHTML = ''; 184 | gameoverText3.append( 185 | oxEmojiWrapper, 186 | ' ', 187 | goatEmojiWrapper, 188 | ' ', 189 | fishEmojiWrapper, 190 | ' ', 191 | scoreWrapper, 192 | ); 193 | 194 | soundToggleButton.style.transition = `all .2s`; 195 | gridRedToggleButton.style.transition = `all .2s`; 196 | gridToggleButton.style.transition = `all .2s`; 197 | soundToggleButton.style.opacity = 0; 198 | gridRedToggleButton.style.opacity = 0; 199 | gridToggleButton.style.opacity = 0; 200 | scoreCounters.style.opacity = 0; 201 | 202 | setTimeout(() => { 203 | toggleGameoverlayButton.style.pointerEvents = ''; // Is separate from the gameoverWrapper 204 | gameoverWrapper.style.pointerEvents = ''; 205 | gameoverWrapper.style.opacity = 1; 206 | menuBackground.style.opacity = 1; 207 | gameoverHeader.style.opacity = 1; 208 | gameoverText1.style.opacity = 1; 209 | gameoverText2.style.opacity = 1; 210 | gameoverText3.style.opacity = 1; 211 | restartButtonWrapper.style.opacity = 1; 212 | menuButtonWrapper.style.opacity = 1; 213 | toggleGameoverlayButton.style.opacity = 1; 214 | }); 215 | }; 216 | 217 | export const hideGameover = () => { 218 | gameoverWrapper.style.transition = `opacity 1s 2s`; 219 | menuBackground.style.transition = `opacity 1s 1s`; 220 | gameoverHeader.style.transition = `opacity .3s .6s`; 221 | gameoverText1.style.transition = `opacity .3s .5s`; 222 | gameoverText2.style.transition = `opacity .3s .4s`; 223 | gameoverText3.style.transition = `opacity .3s .3s`; 224 | restartButtonWrapper.style.transition = `opacity .3s .2s`; 225 | menuButtonWrapper.style.transition = `opacity .3s .1s`; 226 | 227 | gameoverWrapper.style.pointerEvents = 'none'; 228 | gameoverWrapper.style.opacity = 0; 229 | menuBackground.style.opacity = 0; 230 | gameoverHeader.style.opacity = 0; 231 | gameoverText1.style.opacity = 0; 232 | gameoverText2.style.opacity = 0; 233 | gameoverText3.style.opacity = 0; 234 | restartButtonWrapper.style.opacity = 0; 235 | menuButtonWrapper.style.opacity = 0; 236 | }; 237 | -------------------------------------------------------------------------------- /src/goat-emoji.js: -------------------------------------------------------------------------------- 1 | import { createSvgElement } from './svg-utils'; 2 | import { colors } from './colors'; 3 | 4 | export const emojiGoat = () => { 5 | const emojiGoat = createSvgElement(); 6 | emojiGoat.setAttribute('viewBox', '0 0 20 20'); 7 | emojiGoat.setAttribute('stroke-linecap', 'round'); 8 | emojiGoat.style.width = '48px'; 9 | emojiGoat.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 | emojiGoat.append(horn1, horn2, beard, body, eye); 33 | 34 | return emojiGoat; 35 | }; 36 | -------------------------------------------------------------------------------- /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/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/grid-toggle.js: -------------------------------------------------------------------------------- 1 | import { svgHazardLines, svgHazardLinesRed } from './svg'; 2 | import { gridRect, gridRectRed } from './grid'; 3 | import { gridPointerLayer } from './layers'; 4 | import { gridToggleButton, gridToggleSvgPath, gridRedToggleSvgPath, gridToggleTooltip, gridRedToggleTooltip } from './ui'; 5 | import { initAudio, playSound } from './audio'; 6 | 7 | let gridLocked = localStorage.getItem('Tiny Yurtsg') === 'true' ? true : false; 8 | 9 | export const gridRedState = { 10 | locked: false, 11 | on: false, 12 | }; 13 | 14 | export const gridShow = () => { 15 | svgHazardLines.style.opacity = 0.9; 16 | gridRect.style.opacity = 1; 17 | 18 | if (!gridLocked) { 19 | // # 20 | gridToggleSvgPath.setAttribute('d', 'M6 5 6 11M10 5 10 11M5 6 8 6 11 6M5 10 11 10'); 21 | gridToggleSvgPath.style.transform = 'rotate(180deg)'; 22 | } 23 | }; 24 | 25 | export const gridHide = () => { 26 | if (!gridLocked) { 27 | svgHazardLines.style.opacity = 0; 28 | gridRect.style.opacity = 0; 29 | 30 | // A 31 | gridToggleSvgPath.setAttribute('d', 'M8 4.5 5 11M8 4.5 11 11M5 11 8 4.5 11 11M6 9.5 10 9.5'); 32 | gridToggleSvgPath.style.transform = 'rotate(0)'; 33 | } 34 | }; 35 | 36 | if (gridLocked) { 37 | gridToggleTooltip.innerHTML = 'Grid: On'; 38 | gridShow(); 39 | gridToggleSvgPath.setAttribute('d', 'M6 5 6 11M10 5 10 11M5 6 8 6 11 6M5 10 11 10'); 40 | gridToggleSvgPath.style.transform = 'rotate(180deg)'; 41 | } else { 42 | gridToggleTooltip.innerHTML = 'Grid: Auto'; 43 | gridHide(); 44 | } 45 | 46 | export const gridLockToggle = () => { 47 | initAudio(); 48 | 49 | if (gridLocked) { 50 | gridLocked = false; 51 | gridHide(); 52 | localStorage.setItem('Tiny Yurtsg', false); 53 | gridToggleTooltip.innerHTML = 'Grid: Auto'; 54 | } else { 55 | gridShow(); 56 | localStorage.setItem('Tiny Yurtsg', true); 57 | gridLocked = true; 58 | gridToggleTooltip.innerHTML = 'Grid: On'; 59 | } 60 | 61 | playSound(25, 1, 1, 1, 0.3, 1000, 1000); 62 | }; 63 | 64 | export const gridRedShow = () => { 65 | gridPointerLayer.style.cursor = 'crosshair'; 66 | gridRectRed.style.opacity = 0.9; 67 | svgHazardLinesRed.style.opacity = 0.9; 68 | 69 | if (!gridRedState.locked) { 70 | // ☒ (trash / bulldoze mode) 71 | gridRedToggleSvgPath.setAttribute( 72 | 'd', 73 | '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' 74 | ); 75 | gridRedToggleSvgPath.style.transform = 'rotate(180deg)'; 76 | } 77 | }; 78 | 79 | export const gridRedHide = () => { 80 | if (!gridRedState.locked) { 81 | gridRectRed.style.opacity = 0; 82 | svgHazardLinesRed.style.opacity = 0; 83 | 84 | // Right Click 85 | 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'); 86 | gridRedToggleSvgPath.style.transform = 'rotate(0)'; 87 | } 88 | }; 89 | 90 | if (gridRedState.locked) { 91 | gridRedToggleTooltip.innerHTML = 'Delete: On'; 92 | // ☒ (trash / bulldoze mode) 93 | gridRedToggleSvgPath.setAttribute( 94 | 'd', 95 | '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' 96 | ); 97 | gridRedToggleSvgPath.style.transform = 'rotate(180deg)'; 98 | } else { 99 | gridRedToggleTooltip.innerHTML = 'Delete: RMB'; 100 | // A 101 | 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'); 102 | gridRedToggleSvgPath.style.transform = 'rotate(0)'; 103 | } 104 | 105 | export const gridRedLockToggle = () => { 106 | initAudio(); 107 | 108 | if (gridRedState.locked) { 109 | gridRedState.locked = false; 110 | gridRedHide(); 111 | gridRedToggleTooltip.innerHTML = 'Delete: RMB'; 112 | } else { 113 | gridRedShow(); 114 | gridRedState.locked = true; 115 | gridRedToggleTooltip.innerHTML = 'Delete: On'; 116 | } 117 | 118 | playSound(27, 1, 1, 1, 0.3, 1000, 1000); 119 | }; 120 | -------------------------------------------------------------------------------- /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/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 hull = (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 (points[i].y < points[leftmost].y || (points[i].y === points[leftmost].y && points[i].x < points[leftmost].x)) { 14 | leftmost = i; 15 | } 16 | } 17 | 18 | const hull = []; 19 | let p = leftmost; 20 | 21 | do { 22 | hull.push(points[p]); 23 | let q = (p + 1) % points.length; 24 | 25 | for (let i = 0; i < points.length; i++) { 26 | if (orientation(points[p], points[i], points[q]) === 2) { 27 | q = i; 28 | } 29 | } 30 | 31 | p = q; 32 | } while (p !== leftmost); 33 | 34 | // Now, perform a true right-to-left pass to include points on the underside of the shape 35 | p = leftmost; 36 | 37 | do { 38 | let q = (p - 1 + points.length) % points.length; // Go backward in the array 39 | 40 | for (let i = 0; i < points.length; i++) { 41 | if (orientation(points[p], points[i], points[q]) === 2) { 42 | q = i; 43 | } 44 | } 45 | 46 | p = q; 47 | if (p !== leftmost) { 48 | hull.push(points[p]); 49 | } 50 | } while (p !== leftmost); 51 | 52 | return hull; 53 | }; 54 | -------------------------------------------------------------------------------- /src/inventory.js: -------------------------------------------------------------------------------- 1 | export const inventory = { 2 | paths: 18, 3 | }; 4 | -------------------------------------------------------------------------------- /src/keyboard.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/js13kGames/tiny-yurts/484987aa10a187a1a94cc1f8ca042dca864e3153/src/keyboard.js -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { GameLoop } from './modified-kontra/game-loop'; 2 | import { 3 | svgElement, gridWidth, gridHeight, boardOffsetX, boardOffsetY, gridCellSize, boardWidth, boardHeight, svgHazardLines, 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 | import { 12 | initUi, scoreCounters, goatCounter, goatCounterWrapper, oxCounter, oxCounterWrapper, fishCounter, fishCounterWrapper, pathTilesIndicator, pathTilesIndicatorCount, clock, clockHand, clockMonth, pauseButton, pauseSvgPath, gridToggleButton, gridRedToggleButton, soundToggleSvgPath, soundToggleButton, soundToggleTooltip, soundToggleSvgPathX, gridRedToggleTooltip, gridToggleTooltip 13 | } from './ui'; 14 | import { farms } from './farm'; 15 | import { svgPxToDisplayPx } from './cell'; 16 | import { spawnNewObjects } from './spawning'; 17 | import { demoColors } from './demo-colors'; 18 | import { animals } from './animal'; 19 | import { oxen } from './ox'; 20 | import { goats } from './goat'; 21 | import { fishes } from './fish'; 22 | import { ponds } from './pond'; 23 | import { yurts } from './yurt'; 24 | import { paths } from './path'; 25 | import { clearLayers } from './layers'; 26 | import { initMenuBackground } from './menu-background'; 27 | import { initGameover, showGameover, hideGameover, toggleGameoverlayButton } from './gameover'; 28 | import { initMenu, showMenu, hideMenu } from './menu'; 29 | import { updateGridData } from './find-route'; 30 | import { 31 | gridLockToggle, gridRedLockToggle, gridRedHide, gridRedState, 32 | } from './grid-toggle'; 33 | import { gridRect, gridRectRed } from './grid'; 34 | import { colors } from './colors'; 35 | // import { Tree, trees } from './tree'; 36 | import { initAudio, playPathPlacementNote, playWarnNote, soundSetings, playSound, playPathDeleteNote, playTreeDeleteNote, playYurtSpawnNote, playOutOfPathsNote } from './audio'; 37 | 38 | let updateCount = 0; 39 | let renderCount = 0; 40 | let totalUpdateCount = 0; 41 | let gameOverlayHidden; 42 | let lostFarmPosition; 43 | 44 | const startNewGame = () => { 45 | svgElement.style.transition = `transform 2s`; 46 | svgElement.style.transform = `rotate(0) scale(2) translate(0, ${svgPxToDisplayPx(0, gridHeight).y / -2}px)`; 47 | 48 | soundToggleButton.style.transition = `all .2s, width.5s 4s, opacity .5s 3s`; 49 | gridRedToggleButton.style.transition = `all .2s, width.5s 4s, opacity .5s 3s`; 50 | gridToggleButton.style.transition = `all .2s, width .5s 4s, opacity .5s 3s`; 51 | 52 | soundToggleTooltip.style.transition = `all .5s`; 53 | gridRedToggleTooltip.style.transition = `all .5s`; 54 | gridToggleTooltip.style.transition = `all .5s`; 55 | 56 | soundToggleButton.style.opacity = 1; 57 | gridRedToggleButton.style.opacity = 1; 58 | gridToggleButton.style.opacity = 1; 59 | 60 | oxCounterWrapper.style.width = 0; 61 | goatCounterWrapper.style.width = 0; 62 | fishCounterWrapper.style.width = 0; 63 | oxCounterWrapper.style.opacity = 0; 64 | goatCounterWrapper.style.opacity = 0; 65 | fishCounterWrapper.style.opacity = 0; 66 | oxCounter.innerText = 0; 67 | goatCounter.innerText = 0; 68 | fishCounter.innerText = 0; 69 | pauseButton.style.opacity = 0; 70 | 71 | toggleGameoverlayButton.style.opacity = 0; 72 | toggleGameoverlayButton.style.pointerEvents = 'none'; 73 | toggleGameoverlayButton.style.transition = `all .2s, opacity .5s`; 74 | 75 | setTimeout(() => { 76 | goatFarms.length = 0; 77 | oxFarms.length = 0; 78 | people.length = 0; 79 | farms.length = 0; 80 | animals.length = 0; 81 | oxen.length = 0; 82 | goats.length = 0; 83 | fishes.length = 0; 84 | yurts.length = 0; 85 | paths.length = 0; 86 | ponds.length = 0; 87 | updateCount = 1; 88 | totalUpdateCount = 1; 89 | renderCount = 1; 90 | inventory.paths = 18; 91 | pathTilesIndicatorCount.innerText = inventory.paths; 92 | clearLayers(); 93 | hideGameover(); 94 | svgElement.style.transform = ''; 95 | 96 | setTimeout(() => { 97 | spawnNewObjects(0); 98 | loop.start(); 99 | }, 1000); 100 | }, 1000); 101 | }; 102 | 103 | let gameStarted = false; 104 | 105 | const gameoverToMenu = () => { 106 | gameStarted = false; 107 | 108 | svgElement.style.transition = `transform 2s`; 109 | svgElement.style.transform = `rotate(0) scale(2) translate(0, ${svgPxToDisplayPx(0, gridHeight).y / -2}px)`; 110 | 111 | inventory.paths = 18; 112 | 113 | oxCounterWrapper.style.width = 0; 114 | goatCounterWrapper.style.width = 0; 115 | fishCounterWrapper.style.width = 0; 116 | oxCounterWrapper.style.opacity = 0; 117 | goatCounterWrapper.style.opacity = 0; 118 | fishCounterWrapper.style.opacity = 0; 119 | oxCounter.innerText = 0; 120 | goatCounter.innerText = 0; 121 | fishCounter.innerText = 0; 122 | 123 | toggleGameoverlayButton.style.opacity = 0; 124 | toggleGameoverlayButton.style.pointerEvents = 'none'; 125 | toggleGameoverlayButton.style.transition = `all .2s, opacity .5s`; 126 | 127 | soundToggleTooltip.style.transition = `all.2s,width.5s 4s,opacity.5s 4s`; 128 | gridRedToggleTooltip.style.transition = `all.2s,width.5s 4s,opacity.5s 4s`; 129 | gridToggleTooltip.style.transition = `all.2s,width.5s 4s,opacity.5s 4s`; 130 | soundToggleButton.style.transition = `all.2s,width.5s 4s,opacity.5s 4s`; 131 | gridRedToggleButton.style.transition = `all.2s,width.5s 4s,opacity.5s 4s`; 132 | gridToggleButton.style.transition = `all.2s,width.5s 4s,opacity.5s 4s`; 133 | 134 | soundToggleTooltip.style.width = '96px'; 135 | gridRedToggleTooltip.style.width = '96px'; 136 | gridToggleTooltip.style.width = '96px'; 137 | soundToggleTooltip.style.opacity = 1; 138 | gridRedToggleTooltip.style.opacity = 1; 139 | gridToggleTooltip.style.opacity = 1; 140 | soundToggleButton.style.opacity = 1; 141 | gridRedToggleButton.style.opacity = 1; 142 | gridToggleButton.style.opacity = 1; 143 | 144 | setTimeout(() => { 145 | goatFarms.length = 0; 146 | oxFarms.length = 0; 147 | people.length = 0; 148 | farms.length = 0; 149 | animals.length = 0; 150 | oxen.length = 0; 151 | goats.length = 0; 152 | fishes.length = 0; 153 | yurts.length = 0; 154 | paths.length = 0; 155 | ponds.length = 0; 156 | updateCount = 0; 157 | renderCount = 0; 158 | totalUpdateCount = 0; 159 | clearLayers(); 160 | hideGameover(); 161 | svgElement.style.transform = ''; 162 | pathTilesIndicatorCount.innerText = inventory.paths; 163 | 164 | setTimeout(() => { 165 | spawnNewObjects(totalUpdateCount, gameStarted, 2000); 166 | showMenu(farms[0]); 167 | loop.start(); 168 | }, 750); 169 | }, 500); 170 | }; 171 | 172 | const toggleGameoverlay = () => { 173 | if (gameOverlayHidden) { 174 | gameOverlayHidden = false; 175 | svgElement.style.transform = `rotate(-17deg) scale(2) translate(${-lostFarmPosition.x}px, ${-lostFarmPosition.y}px)`; 176 | showGameover(); 177 | } else { 178 | gameOverlayHidden = true; 179 | svgElement.style.transform = ''; 180 | hideGameover(); 181 | } 182 | } 183 | 184 | initUi(); 185 | initMenuBackground(); 186 | initGameover(startNewGame, gameoverToMenu, toggleGameoverlay); 187 | initPointer(); 188 | 189 | const startGame = () => { 190 | svgElement.style.transition = `transform 2s`; 191 | svgElement.style.transform = ''; 192 | pathTilesIndicatorCount.innerText = inventory.paths; 193 | hideMenu(); 194 | gameStarted = true; 195 | updateCount = totalUpdateCount = 1; 196 | 197 | soundToggleTooltip.style.transition = `all.5s`; 198 | gridRedToggleTooltip.style.transition = `all.5s`; 199 | gridToggleTooltip.style.transition = `all.5s`; 200 | 201 | soundToggleButton.style.opacity = 1; 202 | gridRedToggleButton.style.opacity = 1; 203 | gridToggleButton.style.opacity = 1; 204 | }; 205 | 206 | // demoColors(); 207 | initMenu(startGame); 208 | // spawnTrees(); 209 | spawnNewObjects(totalUpdateCount, 2500); 210 | 211 | showMenu(farms[0], true); 212 | 213 | const loop = GameLoop({ 214 | blur: true, // Still update and render even if the page does not have focus 215 | update() { 216 | if (gameStarted) { 217 | spawnNewObjects(totalUpdateCount, gameStarted); 218 | 219 | // if (totalUpdateCount === 90) { 220 | // gridRedToggleButton.style.opacity = 1; 221 | // } 222 | 223 | if (totalUpdateCount === 120) { 224 | scoreCounters.style.opacity = 1; 225 | } 226 | 227 | if (totalUpdateCount === 150) { 228 | pathTilesIndicator.style.opacity = 1; 229 | } 230 | 231 | if (totalUpdateCount === 180) { 232 | clock.style.opacity = 1; 233 | } 234 | 235 | if (totalUpdateCount === 210) { 236 | pauseButton.style.opacity = 1; 237 | } 238 | 239 | if (totalUpdateCount % (720 * 12) === 0 && inventory.paths < 99) { // 720 240 | pathTilesIndicator.style.scale = 1.1; 241 | 242 | pathTilesIndicatorCount.innerText = '+9'; 243 | 244 | setTimeout(() => pathTilesIndicatorCount.innerText = inventory.paths, 1300); 245 | 246 | for (let i = 0; i < 9; i++) { 247 | setTimeout(() => { 248 | if (inventory.paths < 99) { 249 | inventory.paths++; 250 | pathTilesIndicatorCount.innerText = inventory.paths; 251 | } 252 | }, 1300 + 100 * i); 253 | } 254 | 255 | setTimeout(() => { 256 | pathTilesIndicator.style.scale = 1; 257 | }, 300); 258 | } 259 | 260 | // Updating this at 60FPS is a bit much but rotates are usually on the GPU anyway 261 | clockHand.style.transform = `rotate(${totalUpdateCount / 2}deg)`; 262 | switch (Math.floor(totalUpdateCount / 720 % 12)) { 263 | case 0: clockMonth.innerText = 'Jan'; break; 264 | case 1: clockMonth.innerText = 'Feb'; break; 265 | case 2: clockMonth.innerText = 'Mar'; break; 266 | case 3: clockMonth.innerText = 'Apr'; break; 267 | case 4: clockMonth.innerText = 'May'; break; 268 | case 5: clockMonth.innerText = 'Jun'; break; 269 | case 6: clockMonth.innerText = 'Jul'; break; 270 | case 7: clockMonth.innerText = 'Aug'; break; 271 | case 8: clockMonth.innerText = 'Sep'; break; 272 | case 9: clockMonth.innerText = 'Oct'; break; 273 | case 10: clockMonth.innerText = 'Nov'; break; 274 | case 11: clockMonth.innerText = 'Dec'; break; 275 | } 276 | } 277 | 278 | updateCount++; 279 | totalUpdateCount++; 280 | 281 | // Some things happen 15 times/s instead of 60. 282 | // E.g. because movement handled with CSS transitions will be done at browser FPS anyway 283 | /* eslint-disable default-case */ 284 | switch (updateCount % 4) { 285 | case 0: 286 | // Update path grid data once every 4 updates (15 times per second) instead of 287 | // every single time pathfinding is updated which was 6000 time per second(?) 288 | updateGridData(); 289 | break; 290 | case 1: 291 | oxFarms.forEach((farm) => farm.update(gameStarted, totalUpdateCount)); 292 | break; 293 | case 2: 294 | goatFarms.forEach((farm) => farm.update(gameStarted, totalUpdateCount)); 295 | break; 296 | case 3: 297 | fishFarms.forEach((farm) => farm.update(gameStarted, totalUpdateCount)); 298 | break; 299 | } 300 | 301 | if (updateCount >= 60) updateCount = 0; 302 | 303 | farms.forEach((f) => { 304 | if (!f.isAlive) { 305 | loop.stop(); 306 | 307 | lostFarmPosition = svgPxToDisplayPx( 308 | f.x - gridWidth / 2 - boardOffsetX + f.width / 2, 309 | f.y - gridHeight / 2 - boardOffsetY + f.height / 2, 310 | ); 311 | 312 | svgElement.style.transition = `transform 2s ease-out .5s`; 313 | svgElement.style.transform = `rotate(-17deg) scale(2) translate(${-lostFarmPosition.x}px, ${-lostFarmPosition.y}px)`; 314 | 315 | oxCounterWrapper.style.opacity = 0; 316 | goatCounterWrapper.style.opacity = 0; 317 | fishCounterWrapper.style.opacity = 0; 318 | clock.style.opacity = 0; 319 | pathTilesIndicator.style.opacity = 0; 320 | pauseButton.style.opacity = 0; 321 | gridRedState.on = false; 322 | gridRedState.buttonShown = false; 323 | gridRedHide(); 324 | 325 | showGameover(startNewGame); 326 | } 327 | }); 328 | 329 | people.forEach((p) => p.update()); 330 | }, 331 | render() { 332 | renderCount++; 333 | 334 | // Some things happen 15 times/s instead of 60. 335 | // E.g. because movement handled with CSS transitions will be done at browser FPS anyway 336 | switch (renderCount % 4) { 337 | case 0: 338 | // console.log(`Difficulty ramp: ${(totalUpdateCount * totalUpdateCount) / 1e12}`); 339 | // pathTilesIndicatorCount.innerText = inventory.paths; 340 | // if (inventory.paths = 18== 0) { 341 | // pathTilesIndicatorCount.style.background = colors.red; 342 | // pathTilesIndicatorCount.style.color = '#fff'; 343 | // } else { 344 | // pathTilesIndicatorCount.style.background = '#eee'; 345 | // pathTilesIndicatorCount.style.color = colors.ui; 346 | // } 347 | // TODO: Highlight in some way if 0 paths left 348 | break; 349 | case 1: 350 | oxFarms.forEach((farm) => farm.render()); 351 | break; 352 | case 2: 353 | goatFarms.forEach((farm) => farm.render()); 354 | break; 355 | case 3: 356 | fishFarms.forEach((farm) => farm.render()); 357 | break; 358 | } 359 | if (renderCount >= 60) renderCount = 0; 360 | 361 | people.forEach((p) => p.render()); 362 | }, 363 | }); 364 | 365 | const togglePause = () => { 366 | if (loop.isStopped) { 367 | loop.start(); 368 | pauseSvgPath.setAttribute('d', 'M6 6 6 10M10 6 10 8 10 10'); 369 | pauseSvgPath.style.transform = 'rotate(180deg)'; 370 | } else { 371 | loop.stop(); 372 | pauseSvgPath.setAttribute('d', 'M7 6 7 10M7 6 10 8 7 10'); 373 | pauseSvgPath.style.transform = 'rotate(0)'; 374 | } 375 | }; 376 | 377 | const toggleSound = () => { 378 | initAudio(); 379 | 380 | if (soundSetings.on) { 381 | soundSetings.on = false; 382 | localStorage.setItem('Tiny Yurtss', false); 383 | soundToggleSvgPathX.setAttribute('d', 'M11 7Q10 8 9 9M9 7Q10 8 11 9'); 384 | soundToggleSvgPathX.style.stroke = colors.red; 385 | soundToggleTooltip.innerHTML = 'Sound: Off'; 386 | } else { 387 | soundSetings.on = true; 388 | localStorage.setItem('Tiny Yurtss', true); 389 | soundToggleSvgPathX.setAttribute('d', 'M10 6Q12 8 10 10M10 6Q12 8 10 10'); 390 | soundToggleSvgPathX.style.stroke = colors.ui; 391 | soundToggleTooltip.innerHTML = 'Sound: On'; 392 | } 393 | 394 | // This returns before playing if soundSettings.on === false 395 | // frequencyIndex, noteLength, playbackRate, pingyness, volume, lowpass, highpass 396 | playSound(30, 1, 1, 1, 0.3, 1000, 1000); 397 | }; 398 | 399 | if (soundSetings.on) { 400 | soundToggleSvgPathX.setAttribute('d', 'M10 6Q12 8 10 10M10 6Q12 8 10 10'); 401 | soundToggleSvgPathX.style.stroke = colors.ui; 402 | soundToggleTooltip.innerHTML = 'Sound: On'; 403 | } else { 404 | soundToggleSvgPathX.setAttribute('d', 'M11 7Q10 8 9 9M9 7Q10 8 11 9'); 405 | soundToggleSvgPathX.style.stroke = colors.red; 406 | soundToggleTooltip.innerHTML = 'Sound: Off'; 407 | } 408 | 409 | pauseButton.addEventListener('click', togglePause); 410 | gridRedToggleButton.addEventListener('click', gridRedLockToggle); 411 | gridToggleButton.addEventListener('click', gridLockToggle); 412 | soundToggleButton.addEventListener('click', toggleSound); 413 | soundToggleTooltip.addEventListener('click', () => soundToggleButton.click()); 414 | gridRedToggleTooltip.addEventListener('click', () => gridRedToggleButton.click()); 415 | gridToggleTooltip.addEventListener('click', () => gridToggleButton.click()); 416 | 417 | document.addEventListener('keypress', (event) => { 418 | if (event.key === ' ') { 419 | // Prevent double-toggling by having the button be focused when pressing space 420 | if (event.target !== pauseButton) { 421 | togglePause(); 422 | } 423 | 424 | // Simulate :active styles 425 | pauseButton.style.transform = 'scale(.95)'; 426 | setTimeout(() => pauseButton.style.transform = '', 150); 427 | } 428 | 429 | // initAudio(); 430 | 431 | // if (event.key === 'o') playWarnNote(colors.ox); 432 | // if (event.key === 'g') playWarnNote(colors.goat); 433 | // if (event.key === 'f') playWarnNote(colors.fish); 434 | // if (event.key === 'p') playPathPlacementNote(); 435 | // if (event.key === 'r') playPathDeleteNote(); 436 | // if (event.key === 't') playTreeDeleteNote(); 437 | // if (event.key === 'y') playYurtSpawnNote(); 438 | // if (event.key === 'n') playOutOfPathsNote(); // 'n'o paths 439 | }); 440 | 441 | setTimeout(() => { 442 | loop.start(); 443 | }, 1000); 444 | -------------------------------------------------------------------------------- /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/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 { gridToggleTooltip, gridRedToggleTooltip, soundToggleTooltip, uiContainer } from './ui'; 8 | import { initAudio, playSound } from './audio'; 9 | 10 | const menuWrapper = createElement(); 11 | const menuHeader = createElement(); 12 | export const menuText1 = createElement(); 13 | const menuButtons = createElement(); 14 | const startButtonWrapper = createElement(); 15 | const startButton = createElement('button'); 16 | const fullscreenButtonWrapper = createElement(); 17 | const fullscreenButton = createElement('button'); 18 | 19 | export const initMenu = (startGame) => { 20 | menuWrapper.style.cssText = ` 21 | position: absolute; 22 | inset: 0; 23 | padding: 10vmin; 24 | display: flex; 25 | flex-direction: column; 26 | `; 27 | menuWrapper.style.pointerEvents = 'none'; 28 | 29 | // This has to be a sibling element, behind the gameoverScreen, not a child of it, 30 | // so that the backdrop-filter can transition properly 31 | menuBackground.style.clipPath = 'polygon(0 0, calc(20dvw + 400px) 0, calc(20dvw + 350px) 100%, 0 100%)'; 32 | 33 | menuHeader.style.cssText = `font-size: 72px; opacity: 0;`; 34 | menuHeader.innerText = 'Tiny Yurts'; 35 | 36 | // Everything but bottom margin 37 | menuText1.style.cssText = `margin: auto 4px 0; opacity:0;`; 38 | 39 | if (localStorage.getItem('Tiny Yurts')) { 40 | menuText1.innerText = `Highscore: ${localStorage.getItem('Tiny Yurts')}`; 41 | } 42 | 43 | startButton.innerText = 'Start'; 44 | startButton.addEventListener('click', () => { 45 | initAudio(); 46 | startGame(); 47 | }); 48 | startButtonWrapper.style.opacity = 0; 49 | 50 | fullscreenButton.innerText = 'Fullscreen'; 51 | fullscreenButton.addEventListener('click', () => { 52 | initAudio(); 53 | 54 | if (document.fullscreenElement) { 55 | document.exitFullscreen(); 56 | } else { 57 | document.documentElement.requestFullscreen(); 58 | // console.log(screen.orientation.lock()); 59 | // The catch prevents an error on browsers that do not support or do not want 60 | // to allow locking to landscape. Could remove, but having an error is risky 61 | screen.orientation.lock('landscape').catch(() => {}); 62 | } 63 | }); 64 | fullscreenButtonWrapper.style.opacity = 0; 65 | 66 | menuButtons.style.cssText = `display: grid; gap: 16px; margin-top: 48px;`; 67 | startButtonWrapper.append(startButton); 68 | fullscreenButtonWrapper.append(fullscreenButton); 69 | 70 | menuButtons.append(fullscreenButtonWrapper, startButtonWrapper); 71 | 72 | menuWrapper.append(menuHeader, menuButtons, menuText1); 73 | 74 | document.body.append(menuWrapper); 75 | }; 76 | 77 | export const showMenu = (focus, firstTime) => { 78 | menuWrapper.style.pointerEvents = ''; 79 | menuBackground.style.clipPath = `polygon(0 0, calc(20dvw + 400px) 0, calc(20dvw + 350px) 100%, 0 100%)`; 80 | menuBackground.style.transition = `clip-path 1s, opacity 2s`; 81 | menuHeader.style.transition = `opacity .5s 1s`; 82 | fullscreenButtonWrapper.style.transition = `opacity .5s 1.2s`; 83 | startButtonWrapper.style.transition = `opacity .5s 1.4s`; 84 | menuText1.style.transition = `opacity .5s 1.6s`; 85 | 86 | // First time the game is loaded, the menu background needs to be fast 87 | if (firstTime) { 88 | menuBackground.style.transition = `opacity 0s`; 89 | menuHeader.style.transition = `opacity .5s .4s`; 90 | fullscreenButtonWrapper.style.transition = `opacity .5s .6s`; 91 | startButtonWrapper.style.transition = `opacity .5s .8s`; 92 | menuText1.style.transition = `opacity .5s 1s`; 93 | } 94 | 95 | menuText1.innerHTML = localStorage.getItem('Tiny Yurts') 96 | ? `Highscore: ${localStorage.getItem('Tiny Yurts')}` 97 | : 'Tip: Left click & drag to connect yurts to
farms, or delete paths with right click.' 98 | 99 | const farmPxPosition = svgPxToDisplayPx( 100 | focus.x - gridWidth / 2 - boardOffsetX + focus.width / 2, 101 | focus.y - gridHeight / 2 - boardOffsetY + focus.height / 2, 102 | ); 103 | const xOffset = innerWidth / 4; // TODO: Calculate properly? 104 | svgElement.style.transition = ''; 105 | svgElement.style.transform = `translate(${xOffset}px, 0) rotate(-17deg) scale(2) translate(${-farmPxPosition.x}px, ${-farmPxPosition.y}px)`; 106 | 107 | uiContainer.style.zIndex = 1; 108 | menuBackground.style.opacity = 1; 109 | menuHeader.style.opacity = 1; 110 | menuText1.style.opacity = 1; 111 | startButtonWrapper.style.opacity = 1; 112 | fullscreenButtonWrapper.style.opacity = 1; 113 | }; 114 | 115 | export const hideMenu = () => { 116 | menuWrapper.style.pointerEvents = 'none'; 117 | uiContainer.style.zIndex = ''; 118 | 119 | menuBackground.style.transition = `opacity 1s .6s`; 120 | menuHeader.style.transition = `opacity .3s .4s`; 121 | fullscreenButtonWrapper.style.transition = `opacity .3s .3s`; 122 | startButtonWrapper.style.transition = `opacity .3s .2s`; 123 | menuText1.style.transition = `opacity.3s.1s`; 124 | 125 | menuBackground.style.opacity = 0; 126 | fullscreenButtonWrapper.style.opacity = 0; 127 | startButtonWrapper.style.opacity = 0; 128 | fullscreenButtonWrapper.style.transition = 0; 129 | menuText1.style.opacity = 0; 130 | menuHeader.style.opacity = 0; 131 | 132 | soundToggleTooltip.style.opacity = 0; 133 | gridRedToggleTooltip.style.opacity = 0; 134 | gridToggleTooltip.style.opacity = 0; 135 | soundToggleTooltip.style.width = 0; 136 | gridRedToggleTooltip.style.width = 0; 137 | gridToggleTooltip.style.width = 0; 138 | }; 139 | -------------------------------------------------------------------------------- /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/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/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/ox-emoji.js: -------------------------------------------------------------------------------- 1 | import { createSvgElement } from './svg-utils'; 2 | import { colors } from './colors'; 3 | 4 | export const emojiOx = () => { 5 | const emojiOx = createSvgElement(); 6 | emojiOx.setAttribute('viewBox', '0 0 16 16'); 7 | emojiOx.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 | emojiOx.append(body, horn, eye); 23 | 24 | return emojiOx; 25 | }; 26 | -------------------------------------------------------------------------------- /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 | setTimeout(() => this.addAnimal({}), 2000 + properties.delay ?? 0); 20 | setTimeout(() => this.addAnimal({}), 3000 + properties.delay ?? 0); 21 | setTimeout(() => this.addAnimal({ isBaby: (oxFarms.length - 1) % 2 }), 4000 + properties.delay ?? 0); 22 | this.numAnimals = 3; 23 | this.appearing = true; 24 | setTimeout(() => this.appearing = false, 3000); 25 | } 26 | 27 | upgrade() { 28 | // Cannot upgrade if there are 5 or more oxen already 29 | if (this.numAnimals >= 5) { 30 | return false; 31 | } 32 | 33 | this.numAnimals += 2; 34 | 35 | // 3 parents 2 babies each upgrade 36 | for (let i = 0; i < this.children.filter((c) => !c.isBaby).length; 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 Ox({ 47 | parent: this, 48 | isBaby, 49 | })); 50 | } 51 | 52 | update(gameStarted, updateCount) { 53 | super.update(gameStarted, updateCount); 54 | // So 3 ox = 2 demand per update, 5 ox = 2 demand per update, 55 | // so upgrading doubles the demand(?) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /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/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 | { 224 | newPathData.svgElement.style.strokeDasharray = '0 3px'; 225 | newPathData.svgElement.style.strokeWidth = '2px'; 226 | newPathData.svgElement.style.stroke = '#bbb'; 227 | 228 | // Chrome does not support sub-pixel CSS filters, so instead of this, we need another path 229 | // newPathData.svgElement.style.filter = `drop-shadow(.3px .3px ${colors.shade2})`; 230 | 231 | newPathData.svgElementStoneShadow = createSvgElement('path'); 232 | newPathData.svgElementStoneShadow.setAttribute('d', newPathData.d); 233 | newPathData.svgElementStoneShadow.style.transition = `all .4s opacity .2s`; 234 | newPathData.svgElementStoneShadow.style.strokeDasharray = '0 3px'; 235 | newPathData.svgElementStoneShadow.style.strokeWidth = '2px'; 236 | newPathData.svgElementStoneShadow.style.stroke = colors.black; 237 | 238 | rockShadowLayer.append(newPathData.svgElementStoneShadow); 239 | } 240 | 241 | pathLayer.append(newPathData.svgElement); 242 | 243 | // Only transition "new new" single paths 244 | const pathInSameCellRecentlyRemoved = newPathData.path && recentlyRemoved.some((r) => ( 245 | ( 246 | r.x === newPathData.path.points[0].x 247 | && r.y === newPathData.path.points[0].y 248 | ) || ( 249 | r.x === newPathData.path.points[1].x 250 | && r.y === newPathData.path.points[1].y 251 | ) 252 | )); 253 | 254 | const isYurtPath = newPathData.path?.points[0].fixed; 255 | 256 | if (newPathData.path === undefined || !pathInSameCellRecentlyRemoved || isYurtPath) { 257 | newPathData.svgElement.setAttribute('stroke-width', 0); 258 | newPathData.svgElement.setAttribute('opacity', 0); 259 | newPathData.svgElement.style.willChange = `stroke-width, opacity`; 260 | 261 | if (isYurtPath) { 262 | newPathData.svgElement.setAttribute('d', `M${newPathData.M}L${newPathData.M}`); 263 | 264 | setTimeout(() => { 265 | newPathData.svgElement.setAttribute('d', `M${newPathData.M}L${newPathData.L}`); 266 | }, 20); 267 | } 268 | 269 | if (!noShadow) { 270 | newPathData.svgElementShadow = createSvgElement('path'); 271 | newPathData.svgElementShadow.setAttribute('d', newPathData.d); 272 | pathShadowLayer.append(newPathData.svgElementShadow); 273 | 274 | // After transition complete, we don't need the shadow anymore 275 | setTimeout(() => { 276 | newPathData.svgElementShadow?.remove(); 277 | newPathData.svgElement.style.willChange = ''; 278 | }, 500); 279 | } 280 | 281 | setTimeout(() => { 282 | newPathData.svgElement.removeAttribute('stroke-width'); 283 | newPathData.svgElement.setAttribute('opacity', 1); 284 | }, 20); 285 | } 286 | } 287 | }); 288 | 289 | pathsData = [...newPathsData]; 290 | recentlyRemoved = []; 291 | }; 292 | 293 | export class Path extends GameObjectClass { 294 | constructor(properties) { 295 | const { points } = properties; 296 | 297 | super({ 298 | ...properties, 299 | points, 300 | }); 301 | 302 | trees.filter((t) => this.points.some((p) => p.x === t.x && p.y === t.y)).forEach((tree) => tree.remove()); 303 | 304 | paths.push(this); 305 | } 306 | 307 | remove() { 308 | pathsData = pathsData.filter((p) => { 309 | if (p.path === this || p.path1 === this || p.path2 === this) { 310 | p.svgElement.setAttribute('opacity', 0); 311 | p.svgElement.setAttribute('stroke-width', 0); 312 | p.svgElementStoneShadow?.setAttribute('opacity', 0); 313 | p.svgElementStoneShadow?.setAttribute('stroke-width', 0); 314 | 315 | setTimeout(() => { 316 | p.svgElement.remove(); 317 | p.svgElementStoneShadow?.remove(); 318 | }, 500); 319 | return false; 320 | } 321 | 322 | return true; 323 | }); 324 | 325 | // Remove from paths array 326 | paths.splice(paths.findIndex((p) => p === this), 1); 327 | 328 | recentlyRemoved.push( 329 | { x: this.points[0].x, y: this.points[0].y }, 330 | { x: this.points[1].x, y: this.points[1].y }, 331 | ); 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /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 | shuffle(this.farmToVisit.children).forEach((fish, i) => setTimeout(() => fish.svgBody.style.fill = colors.fish, i * 250)); 78 | } 79 | 80 | // After this many updates, go home 81 | // TODO: Make sensible number, show some sort of animation 82 | // originatlRoute.length counts every cell including yurt & farm 83 | if ( 84 | (this.atFarm > 80 && this.originalRoute.length > 3) 85 | || (this.atFarm > 120 && this.originalRoute.length > 2) 86 | || this.atFarm > 160 87 | ) { 88 | if (this.farmToVisit.type === colors.fish) { 89 | shuffle(this.farmToVisit.children).forEach((fish, i) => setTimeout(() => fish.svgBody.style.fill = colors.shade2, 1000 + i * 1000)); 90 | } 91 | 92 | // Go back home. If no route is found, errrr dunno? 93 | const route = findRoute({ 94 | from: { 95 | x: this.destination.x, // from before 96 | y: this.destination.y, 97 | }, 98 | to: [{ 99 | x: this.parent.x, 100 | y: this.parent.y, 101 | }], 102 | }); 103 | 104 | if (route?.length) { 105 | this.goingHome = true; 106 | this.atFarm = 0; 107 | this.hasDestination = true; 108 | this.destination = route.at(-1); 109 | this.route = route; 110 | this.originalRoute = [...route]; 111 | } else { 112 | // Can't find way home :( 113 | // Reset atFarm so that the way home isn't rechecked every single update(!) 114 | // adds in some randomness so that if multiple people are stuck, 115 | // they don't all try to leave at the exact same time 116 | this.atFarm = Math.random() * 40 + 40; 117 | } 118 | } 119 | } 120 | 121 | // If the person has a destination, gotta follow the route to it! 122 | // TODO: We have 3 variables for kinda the same thing but maybe we need them 123 | if (this.hasDestination) { 124 | if (this.destination) { 125 | if (this.route?.length) { 126 | // Head to the first point in the route, and then... remove it when we get there? 127 | const xVariance = Math.random() * 2 - 1; 128 | const yVariance = Math.random() * 2 - 1; 129 | const firstRoutePoint = new Vector( 130 | gridCellSize / 2 + this.route[0].x * gridCellSize + xVariance, 131 | gridCellSize / 2 + this.route[0].y * gridCellSize + yVariance, 132 | ); 133 | 134 | const closeEnough = 2; 135 | const closeEnoughDestination = 1; 136 | 137 | // If a the yurt and farm are adjacent, you don't need to rush... 138 | if (this.originalRoute.length < 3) { 139 | this.dx *= 0.9; 140 | this.dy *= 0.9; 141 | } 142 | 143 | if (this.route.length === 1) { 144 | if ( 145 | Math.abs(this.x - firstRoutePoint.x) < closeEnoughDestination 146 | && Math.abs(this.y - firstRoutePoint.y) < closeEnoughDestination 147 | ) { 148 | if (this.goingHome) { 149 | // TODO: Have like 2 variables for atHome/atFarm/goingHome/goingFarm 150 | this.goingHome = false; 151 | this.atHome = true; 152 | } else { 153 | this.atFarm = 1; 154 | this.farmToVisit.demand -= this.farmToVisit.needyness; 155 | this.farmToVisit.assignedPeople 156 | .splice(this.farmToVisit.assignedPeople.indexOf(this), 1); 157 | // this.farmToVisit.hideWarn(); 158 | // this.animalToVisit.hasPerson = false; 159 | } 160 | this.hasDestination = false; 161 | return; 162 | } 163 | } else if ( 164 | Math.abs(this.x - firstRoutePoint.x) < closeEnough 165 | && Math.abs(this.y - firstRoutePoint.y) < closeEnough 166 | ) { 167 | this.route.shift(); 168 | return; 169 | } 170 | 171 | // Apply a max speed 172 | // Usually < 10 loops with 0.1 and 0.98 173 | while (this.velocity.length() > 0.1) { 174 | this.dx *= 0.98; 175 | this.dy *= 0.98; 176 | } 177 | 178 | const allowedWonkyness = 0.006; 179 | const speed = 0.01; 180 | const vectorToNextpoint = this.position.subtract(firstRoutePoint); 181 | const normalizedVectorToNextPoints = vectorToNextpoint.normalize(); 182 | 183 | if (this.x < firstRoutePoint.x + allowedWonkyness) { 184 | this.dx -= normalizedVectorToNextPoints.x * speed; 185 | } 186 | if (this.x > firstRoutePoint.x - allowedWonkyness) { 187 | this.dx -= normalizedVectorToNextPoints.x * speed; 188 | } 189 | 190 | if (this.y < firstRoutePoint.y + allowedWonkyness) { 191 | this.dy -= normalizedVectorToNextPoints.y * speed; 192 | } 193 | if (this.y > firstRoutePoint.y - allowedWonkyness) { 194 | this.dy -= normalizedVectorToNextPoints.y * speed; 195 | } 196 | // console.log(firstRoutePoint); 197 | } 198 | } 199 | } 200 | 201 | const slowyDistance = 6; 202 | const avoidanceDistance = 1.5; 203 | const turnyness = 0.1; 204 | // Is currently travelling? 205 | // could check velocity instead? 206 | if (this.route?.length > 0) { 207 | const potentialCollisionPeople = people 208 | .filter((otherPerson) => otherPerson !== this && !otherPerson.atHome); 209 | 210 | potentialCollisionPeople.forEach((otherPerson) => { 211 | const distanceBetween = otherPerson.position.distance(this.position); 212 | const nextDistanceBetween = otherPerson.position.distance(this.position.add(this.velocity)); 213 | 214 | if (nextDistanceBetween < distanceBetween) { 215 | if (nextDistanceBetween < avoidanceDistance) { 216 | // TODO: Turn left or right depending on what makes most sense 217 | const vectorBetweenPeople = this.position.subtract(otherPerson.position); 218 | const normalBetweenPeople = vectorBetweenPeople.normalize(); 219 | const turnLeftVector = rotateVector(normalBetweenPeople, (Math.PI / 2)); 220 | const turnLeftVectorScaled = turnLeftVector.scale(turnyness); 221 | this.velocity.set(combineVectors(this.velocity, turnLeftVectorScaled)); 222 | } 223 | } 224 | 225 | const newNextDistanceBetween = otherPerson.position.distance( 226 | this.position.add(this.velocity), 227 | ); 228 | 229 | if (nextDistanceBetween < slowyDistance && this.velocity.length() > 0.06) { 230 | if (newNextDistanceBetween < distanceBetween) { 231 | if (nextDistanceBetween < avoidanceDistance) { 232 | this.dx *= 0.86; 233 | this.dy *= 0.86; 234 | } else { 235 | this.dx *= 0.89; 236 | this.dy *= 0.89; 237 | } 238 | } else { 239 | // Getting further away (still want to go slower than usual) 240 | this.dx *= 0.9; 241 | this.dy *= 0.9; 242 | } 243 | } 244 | }); 245 | } 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /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/pond.js: -------------------------------------------------------------------------------- 1 | import { createSvgElement } from './svg-utils'; 2 | import { hull } 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 = hull(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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/svg-utils.js: -------------------------------------------------------------------------------- 1 | export const createSvgElement = (tag = 'svg') => document.createElementNS('http://www.w3.org/2000/svg', tag); 2 | -------------------------------------------------------------------------------- /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/tree.js: -------------------------------------------------------------------------------- 1 | import { GameObjectClass } from './modified-kontra/game-object'; 2 | import { createSvgElement } from './svg-utils'; 3 | import { gridCellSize } from './svg'; 4 | import { treeShadowLayer, treeLayer } from './layers'; 5 | import { colors } from './colors'; 6 | import { Vector } from 'kontra'; 7 | import { playTreeDeleteNote } from './audio'; 8 | 9 | export const trees = []; 10 | 11 | /** 12 | * Yurts each need to have... 13 | * - types - Ox is brown, goat is grey, etc. 14 | * - number of people currently inside? 15 | * - people belonging to the yurt? 16 | * - x and y coordinate in the grid 17 | */ 18 | 19 | export class Tree extends GameObjectClass { 20 | constructor(properties) { 21 | super({ ...properties }); 22 | 23 | trees.push(this); 24 | this.dots = []; 25 | this.addToSvg(); 26 | } 27 | 28 | addToSvg() { 29 | const minSpaceBetweenDots = 0.5; 30 | const numTrees = Math.random() * 4; 31 | const x = gridCellSize / 2 + this.x * gridCellSize; 32 | const y = gridCellSize / 2 + this.y * gridCellSize; 33 | 34 | this.svgGroup = createSvgElement('g'); 35 | this.svgGroup.style.transform = `translate(${x}px,${y}px)`; 36 | treeLayer.append(this.svgGroup); 37 | 38 | this.shadowGroup = createSvgElement('g'); 39 | this.shadowGroup.style.transform = `translate(${x}px,${y}px)`; 40 | treeShadowLayer.append(this.shadowGroup); 41 | 42 | for (let i = 0; i < numTrees; i++) { 43 | const size = Math.random() / 2 + 1; 44 | const position = new Vector(Math.random() * 8 - 4, Math.random() * 8 - 4); 45 | 46 | // If this new tree (...branch) is too close to another tree in this cell, just skip it. 47 | // This means that on average, larger trees are less likely to have many siblings 48 | if (this.dots.some((d) => d.position.distance(position) < d.size + size + minSpaceBetweenDots)) { 49 | continue; 50 | } 51 | 52 | this.dots.push({ position, size }); 53 | 54 | const circle = createSvgElement('circle'); 55 | circle.style.transform = `translate(${position.x}px, ${position.y}px)`; 56 | circle.setAttribute('fill', colors.leaf); 57 | circle.style.transition = `r .4s cubic-bezier(.5, 1.5, .5, 1)`; 58 | setTimeout(() => circle.setAttribute('r', size), 100 * i); 59 | 60 | this.svgGroup.append(circle); 61 | 62 | const shadow = createSvgElement('ellipse'); 63 | shadow.setAttribute('rx', 0); 64 | shadow.setAttribute('ry', 0); 65 | shadow.style.opacity = 0; 66 | shadow.style.transform = `translate(${position.x}px,${position.y}px) rotate(45deg)`; 67 | shadow.style.transition = `all .4s cubic-bezier(.5, 1.5, .5, 1)`; 68 | setTimeout(() => { 69 | shadow.setAttribute('rx', size * 1.2); 70 | shadow.setAttribute('ry', size * 0.9); 71 | shadow.style.opacity = 0.1; 72 | shadow.style.transform = `translate(${position.x + size * 0.7}px,${position.y + size * 0.7}px) rotate(45deg)`; 73 | }, 100 * i); 74 | this.shadowGroup.append(shadow); 75 | } 76 | } 77 | 78 | remove() { 79 | // Remove from the SVG 80 | this.svgGroup.remove(); 81 | this.shadowGroup.remove(); 82 | 83 | for (let i = 0; i < this.dots.length; i++) { 84 | setTimeout(() => playTreeDeleteNote(), i * 100); 85 | } 86 | 87 | // Remove from trees array 88 | trees.splice(trees.findIndex((p) => p === this), 1); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /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 | // TODO: Move elsewhre and minify 50 | const styles = createElement('style'); 51 | // body has user-select: none; to prevent text being highlighted. 52 | // ui black and shade colours inlined to make things smaller maybe 53 | styles.innerText = ` 54 | body { 55 | position: relative; 56 | font-weight: 700; 57 | font-family: system-ui; 58 | color: ${colors.ui}; 59 | margin: 0; 60 | width: 100vw; 61 | height: 100vh; 62 | user-select: none; 63 | } 64 | button { 65 | font-weight: 700; 66 | font-family: system-ui; 67 | color: ${colors.ui}; 68 | border: none; 69 | padding: 0 20px; 70 | font-size: 32px; 71 | height: 56px; 72 | border-radius: 64px; 73 | background: ${colors.yurt}; 74 | transition: all .2s, bottom .5s, right .5s, opacity 1s; 75 | box-shadow: 0 0 0 1px ${colors.shade}; 76 | } 77 | button:hover { 78 | box-shadow: 4px 4px 0 1px ${colors.shade}; 79 | } 80 | button:active { 81 | transform: scale(.95); 82 | box-shadow: 0 0 0 1px ${colors.shade}; 83 | } 84 | u, abbr { 85 | text-decoration-thickness: 2px; 86 | text-underline-offset: 2px; 87 | } 88 | `; 89 | document.head.append(styles); 90 | 91 | uiContainer.style.cssText = ` 92 | position: absolute; 93 | inset: 0; 94 | display: grid; 95 | overflow: hidden; 96 | pointer-events: none 97 | `; 98 | uiContainer.style.zIndex = 1; 99 | document.body.append(uiContainer); 100 | 101 | scoreCounters.style.cssText = `display:flex;position:absolute;top:16px;left:16px;`; 102 | scoreCounters.style.transition = `opacity 1s`; 103 | scoreCounters.style.opacity = 0; 104 | 105 | oxCounterWrapper.style.cssText = `display:flex;align-items:center;gap:8px;transition:width 1s,opacity 1s 1s`; 106 | const oxCounterEmoji = emojiOx(); 107 | oxCounterWrapper.style.width = 0; 108 | oxCounterWrapper.style.opacity = 0; 109 | oxCounterEmoji.style.width = '48px'; 110 | oxCounterEmoji.style.height = '48px'; 111 | oxCounterWrapper.append(oxCounterEmoji, oxCounter); 112 | 113 | goatCounterWrapper.style.cssText = `display:flex;align-items:center;gap:8px;transition:width 1s,opacity 1s 1s`; 114 | const goatCounterEmoji = emojiGoat(); 115 | goatCounterWrapper.style.width = 0; 116 | goatCounterWrapper.style.opacity = 0; 117 | goatCounterEmoji.style.width = '48px'; 118 | goatCounterEmoji.style.height = '48px'; 119 | goatCounterWrapper.append(goatCounterEmoji, goatCounter); 120 | 121 | fishCounterWrapper.style.cssText = `display:flex;align-items:center;gap:8px;transition:width 1s,opacity 1s 1s`; 122 | const fishCounterEmoji = emojiFish(); 123 | fishCounterWrapper.style.width = 0; 124 | fishCounterWrapper.style.opacity = 0; 125 | fishCounterEmoji.style.width = '48px'; 126 | fishCounterEmoji.style.height = '48px'; 127 | fishCounterWrapper.append(fishCounterEmoji, fishCounter); 128 | 129 | scoreCounters.append(oxCounterWrapper, goatCounterWrapper, fishCounterWrapper); 130 | 131 | clock.style.cssText = ` 132 | position: absolute; 133 | display: grid; 134 | top: 16px; 135 | right: 16px; 136 | place-items: center; 137 | border-radius: 64px; 138 | background: ${colors.ui} 139 | `; 140 | clock.style.width = '80px'; 141 | clock.style.height = '80px'; 142 | clock.style.opacity = 0; 143 | clock.style.transition = `opacity 1s`; 144 | 145 | const clockSvg = createSvgElement('svg'); 146 | clockSvg.setAttribute('stroke-linejoin', 'round'); 147 | clockSvg.setAttribute('stroke-linecap', 'round'); 148 | clockSvg.setAttribute('viewBox', '0 0 16 16'); 149 | clockSvg.style.width = '80px'; 150 | clockSvg.style.height = '80px'; 151 | 152 | for (let i = 75; i < 350; i += 25) { 153 | const dot = createSvgElement('path'); 154 | dot.setAttribute('fill', 'none'); 155 | dot.setAttribute('stroke', '#eee'); 156 | dot.setAttribute('transform-origin', 'center'); 157 | dot.setAttribute('d', 'm8 14.5 0 0'); 158 | dot.style.transform = `rotate(${i}grad)`; 159 | clockSvg.append(dot); 160 | } 161 | 162 | clockHand.setAttribute('stroke', '#eee'); 163 | clockHand.setAttribute('transform-origin', 'center'); 164 | clockHand.setAttribute('d', 'm8 4 0 4'); 165 | clockSvg.append(clockHand); 166 | 167 | clockMonth.style.cssText = `position:absolute;bottom:8px;color:#eee`; 168 | 169 | clock.append(clockSvg, clockMonth); 170 | 171 | pathTilesIndicator.style.cssText = ` 172 | position: absolute; 173 | display: grid; 174 | place-items: center; 175 | place-self: center; 176 | bottom: 20px; 177 | border-radius: 20px; 178 | background: ${colors.ui}; 179 | `; 180 | if (document.body.scrollHeight < 500) { 181 | pathTilesIndicator.style.left = '20px'; 182 | } else { 183 | pathTilesIndicator.style.left = ''; 184 | } 185 | addEventListener('resize', () => { 186 | if (document.body.scrollHeight < 500) { 187 | pathTilesIndicator.style.left = '20px'; 188 | } else { 189 | pathTilesIndicator.style.left = ''; 190 | } 191 | }); 192 | pathTilesIndicator.style.transform = 'rotate(-45deg)'; 193 | pathTilesIndicator.style.opacity = 0; 194 | pathTilesIndicator.style.transition = `scale .4s cubic-bezier(.5, 2, .5, 1), opacity 1s`; 195 | pathTilesIndicator.style.width = '72px'; 196 | pathTilesIndicator.style.height = '72px'; 197 | pathTilesIndicatorCount.style.cssText = ` 198 | position: absolute; 199 | display: grid; 200 | place-items: center; 201 | border-radius: 64px; 202 | border: 6px solid ${colors.ui}; 203 | transform: translate(28px,28px) rotate(45deg); 204 | font-size: 18px; 205 | background: #eee; 206 | transition: all.5s; 207 | }`; 208 | pathTilesIndicatorCount.style.width = '28px'; 209 | pathTilesIndicatorCount.style.height = '28px'; 210 | const pathTilesSvg = createSvgElement('svg'); 211 | pathTilesSvg.setAttribute('viewBox', '0 0 18 18'); 212 | pathTilesSvg.style.width = '54px'; 213 | pathTilesSvg.style.height = '54px'; 214 | pathTilesSvg.style.transform = 'rotate(45deg)'; 215 | const pathTilesSvgPath = createSvgElement('path'); 216 | pathTilesSvgPath.setAttribute('fill', 'none'); 217 | pathTilesSvgPath.setAttribute('stroke', '#eee'); 218 | // pathTilesPath.setAttribute('stroke-linejoin', 'round'); 219 | pathTilesSvgPath.setAttribute('stroke-linecap', 'round'); 220 | pathTilesSvgPath.setAttribute('stroke-width', 2); 221 | 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'); 222 | pathTilesSvg.append(pathTilesSvgPath); 223 | // pathTilesIndicatorInner.append(pathTilesSvg); 224 | // pathTilesIndicatorInner.style.width = '64px'; 225 | // pathTilesIndicatorInner.style.height = '64px'; 226 | // pathTilesIndicatorInner.style.borderRadius = '16px'; // The only non-"infinity"? 227 | pathTilesIndicator.append(pathTilesSvg, pathTilesIndicatorCount); 228 | 229 | const pauseSvg = createSvgElement('svg'); 230 | pauseSvg.setAttribute('viewBox', '0 0 16 16'); 231 | pauseSvg.setAttribute('width', 64); 232 | pauseSvg.setAttribute('height', 64); 233 | pauseSvgPath.setAttribute('fill', colors.ui); 234 | pauseSvgPath.setAttribute('stroke', colors.ui); 235 | pauseSvgPath.setAttribute('stroke-width', 2); 236 | pauseSvgPath.setAttribute('stroke-linecap', 'round'); 237 | pauseSvgPath.setAttribute('stroke-linejoin', 'round'); 238 | pauseSvgPath.setAttribute('d', 'M6 6 6 10M10 6 10 8 10 10'); 239 | pauseSvgPath.style.transition = `all .2s`; 240 | pauseSvgPath.style.transformOrigin = 'center'; 241 | pauseSvgPath.style.transform = 'rotate(180deg)'; 242 | pauseSvg.append(pauseSvgPath); 243 | 244 | pauseButton.style.cssText = `position:absolute;padding:0;pointer-events:all`; 245 | if (document.body.scrollHeight < 500) { 246 | pauseButton.style.top = '108px'; 247 | pauseButton.style.right = '20px'; 248 | } else { 249 | pauseButton.style.top = '24px'; 250 | pauseButton.style.right = '112px'; 251 | } 252 | addEventListener('resize', () => { 253 | if (document.body.scrollHeight < 500) { 254 | pauseButton.style.top = '108px'; 255 | pauseButton.style.right = '20px'; 256 | } else { 257 | pauseButton.style.top = '24px'; 258 | pauseButton.style.right = '112px'; 259 | } 260 | }); 261 | pauseButton.style.width = '64px'; 262 | pauseButton.style.height = '64px'; 263 | pauseButton.style.opacity = 0; 264 | pauseButton.append(pauseSvg); 265 | 266 | gridRedToggleSvg.setAttribute('viewBox', '0 0 16 16'); 267 | gridRedToggleSvg.setAttribute('width', 48); 268 | gridRedToggleSvg.setAttribute('height', 48); 269 | gridRedToggleSvgPath.setAttribute('fill', 'none'); 270 | gridRedToggleSvgPath.setAttribute('stroke', colors.red); 271 | gridRedToggleSvgPath.setAttribute('stroke-width', 2); 272 | gridRedToggleSvgPath.setAttribute('stroke-linecap', 'round'); 273 | gridRedToggleSvgPath.setAttribute('stroke-linejoin', 'round'); 274 | gridRedToggleSvgPath.style.transition = `all .3s`; 275 | gridRedToggleSvgPath.style.transformOrigin = 'center'; 276 | gridRedToggleSvg.append(gridRedToggleSvgPath); 277 | gridRedToggleButton.append(gridRedToggleSvg); 278 | gridRedToggleButton.style.cssText = `position:absolute;bottom:72px;right:16px;padding:0;pointer-events:all;`; 279 | gridRedToggleButton.style.width = '48px'; 280 | gridRedToggleButton.style.height = '48px'; 281 | gridRedToggleTooltip.style.cssText = ` 282 | position: absolute; 283 | display: flex; 284 | right: 16px; 285 | align-items: center; 286 | color: #eee; 287 | font-size: 16px; 288 | border-radius: 64px; 289 | padding: 0 64px 0 16px; 290 | white-space: pre; 291 | pointer-events: all; 292 | bottom: 72px; 293 | background: ${colors.ui}; 294 | `; 295 | gridRedToggleTooltip.style.height = '48px'; 296 | gridRedToggleTooltip.style.width = '96px'; 297 | gridRedToggleTooltip.style.transition = `all .5s`; 298 | 299 | gridToggleSvg.setAttribute('viewBox', '0 0 16 16'); 300 | gridToggleSvg.setAttribute('width', 48); 301 | gridToggleSvg.setAttribute('height', 48); 302 | gridToggleSvgPath.setAttribute('fill', 'none'); 303 | gridToggleSvgPath.setAttribute('stroke', colors.ui); 304 | gridToggleSvgPath.setAttribute('stroke-width', 2); 305 | gridToggleSvgPath.setAttribute('stroke-linecap', 'round'); 306 | gridToggleSvgPath.setAttribute('stroke-linejoin', 'round'); 307 | gridToggleSvgPath.style.transition = `all .3s`; 308 | gridToggleSvgPath.style.transformOrigin = 'center'; 309 | gridToggleSvg.append(gridToggleSvgPath); 310 | gridToggleButton.append(gridToggleSvg); 311 | gridToggleButton.style.cssText = `position:absolute;bottom:16px;right:16px;padding:0;pointer-events:all;`; 312 | gridToggleButton.style.width = '48px'; 313 | gridToggleButton.style.height = '48px'; 314 | gridToggleTooltip.style.cssText = ` 315 | position: absolute; 316 | display: flex; 317 | right: 16px; 318 | align-items: center; 319 | color: #eee; 320 | font-size: 16px; 321 | border-radius: 64px; 322 | padding: 0 64px 0 16px; 323 | white-space: pre; 324 | pointer-events: all; 325 | bottom: 16px; 326 | background: ${colors.ui}; 327 | `; 328 | gridToggleTooltip.style.height = '48px'; 329 | gridToggleTooltip.style.width = '96px'; 330 | gridToggleTooltip.style.transition = `all .5s`; 331 | 332 | soundToggleSvg.setAttribute('viewBox', '0 0 16 16'); 333 | soundToggleSvg.setAttribute('width', 48); 334 | soundToggleSvg.setAttribute('height', 48); 335 | soundToggleSvgPath.setAttribute('fill', 'none'); 336 | soundToggleSvgPath.setAttribute('stroke', colors.ui); 337 | soundToggleSvgPath.setAttribute('stroke-width', 2); 338 | soundToggleSvgPath.setAttribute('stroke-linecap', 'round'); 339 | soundToggleSvgPath.setAttribute('stroke-linejoin', 'round'); 340 | soundToggleSvgPath.style.transition = `all .3s`; 341 | soundToggleSvgPath.style.transformOrigin = 'center'; 342 | soundToggleSvgPath.style.transform = 'rotate(0)'; 343 | soundToggleSvgPath.setAttribute('d', 'M9 13 6 10 4 10 4 6 6 6 9 3'); 344 | soundToggleSvgPathX.setAttribute('fill', 'none'); 345 | soundToggleSvgPathX.setAttribute('stroke', colors.ui); 346 | soundToggleSvgPathX.setAttribute('stroke-width', 2); 347 | soundToggleSvgPathX.setAttribute('stroke-linecap', 'round'); 348 | soundToggleSvgPathX.setAttribute('stroke-linejoin', 'round'); 349 | soundToggleSvgPathX.style.transition = `all .3s`; 350 | soundToggleSvgPathX.style.transformOrigin = 'center'; 351 | soundToggleSvgPathX.style.transform = 'rotate(0)'; 352 | soundToggleSvg.append(soundToggleSvgPath, soundToggleSvgPathX); 353 | soundToggleButton.append(soundToggleSvg); 354 | soundToggleButton.style.cssText = `position:absolute;bottom:128px;right:16px;padding:0;pointer-events:all;`; 355 | soundToggleButton.style.width = '48px'; 356 | soundToggleButton.style.height = '48px'; 357 | soundToggleTooltip.style.cssText = ` 358 | position: absolute; 359 | display: flex; 360 | right: 16px; 361 | align-items: center; 362 | color: #eee; 363 | font-size: 16px; 364 | border-radius: 64px; 365 | padding: 0 64px 0 16px; 366 | white-space: pre; 367 | pointer-events: all; 368 | bottom: 128px; 369 | background: ${colors.ui}; 370 | `; 371 | soundToggleTooltip.style.height = '48px'; 372 | soundToggleTooltip.style.width = '96px'; 373 | soundToggleTooltip.style.transition = `all .5s`; 374 | 375 | uiContainer.append( 376 | scoreCounters, 377 | clock, 378 | pauseButton, 379 | pathTilesIndicator, 380 | gridRedToggleTooltip, 381 | gridRedToggleButton, 382 | gridToggleTooltip, 383 | gridToggleButton, 384 | soundToggleTooltip, 385 | soundToggleButton, 386 | ); 387 | }; 388 | -------------------------------------------------------------------------------- /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/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 null; // TODO: See if returning null increases filesize 15 | }; 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------