├── .gitignore ├── .replit ├── LICENSE ├── README.md ├── favicon.ico ├── package.json ├── plan.txt ├── readme-media ├── anim.gif └── screen.png ├── src ├── .DS_Store ├── appState.js ├── caveGenerator.js ├── events.js ├── evolveAid.js ├── gameFragmentShader.js ├── gameStateTransformer.js ├── index.js ├── quadShaderCanvas.js ├── simulation.js ├── stateTransformer.js ├── style.css └── util.js ├── webpack.config.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | package-lock.json 3 | /dist 4 | -------------------------------------------------------------------------------- /.replit: -------------------------------------------------------------------------------- 1 | language = "nodejs" 2 | run = "npx http-server" 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Weston C. Beecroft 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 | # Under 2 | 3 | [![run on repl.it](http://repl.it/badge/github/westoncb/under-game)](https://repl.it/github/westoncb/under-game) 4 | 5 | Under is a minimal game written in JavaScript and GLSL with procedural graphics produced mostly by noise, signed distance functions, and [boolean/space-folding operators](http://mercury.sexy/hg_sdf/) applied to those functions. The codebase is small and fairly well-documented. 6 | 7 | - [Play here!](http://symbolflux.com/under) (Make sure to play in landscape orientation on mobile!) 8 | - [Youtube video](https://youtu.be/Q010AFPItqY) 9 | 10 | **Controls**: Press up to go up, otherwise you'll go down. Skim the cave edge for more points—but don't run into it! 11 | 12 | ![](readme-media/screen.png) 13 | ![](readme-media/anim.gif) 14 | 15 | ## Contents 16 | - [Project Background](#project-background) 17 | - [Code Overview](#code-overview) 18 | - [Build/Run](#build-and-run) 19 | 20 | ## Project Background 21 | I recently wrapped up a contract and had some free time on my hands, so I decided to make something 80% for fun. The other 20% was to test out some architecture ideas and to see if I could learn something about my personal bottlenecks in doing side projects. I originally planned to spend only 5 days on it, but that rapidly turned into 9 (full days), and I've been tweaking it and adding sounds when I get a few free moments since. So it's an approximately 10 day project. 22 | 23 | The pure fun part was largely that I already knew pretty clearly how to make the game—and in fact, I'd made essentially the [same game](http://symbolflux.com/statichtml/oldprojects/wormgame.html) about 12 years ago—and I knew the technologies involved well, so I could focus almost solely on experimenting with making pretty graphics in GLSL using distance functions and creative ways of combining them (such as you'd run into on e.g. [Shadertoy](https://www.shadertoy.com/)). Additionally, I could enjoy the contrast in what I was able to do with the same project now vs. 12 years go when I was first learning to code :) 24 | 25 | The architecture experiment concept is summed up in something I tweeted the other day: 26 | 27 | *How about an architecture like a discrete dynamical system driven by events instead of time, where the state evolution and event generation logic changes according to a quasi-FSM where nodes are defined by boolean functions (of system state) instead of explicit graph structure?* 28 | 29 | (Note: my conception was only clear enough to describe it that way after mostly completing this project. What you'll find in the code here isn't quite so neat.) 30 | 31 | I was reading about about 'simulating the physical world' via Jamie Wong's [excellent article](http://jamie-wong.com/post/simulating-the-physical-world/) and then started thinking about how 'normal' apps are different, and whether they could benefit by sharing some ideas. It seemed to me that Redux for instance must have been inspired by thinking along these lines (no idea if that's true), and that the general notion of 'operation' has a strong resemblance to differentials in a numerical integration process. 32 | 33 | The other aspect of the architecture experiment was to attempt a pragmatic balance between functional and OO styles. I did all my high-level planning in terms of pure functions, and felt like I'd got most of the app's essentials down in that fashion—but once I started coding I let go of any strict constraints on functions being pure or data being immutable, hoping that the functional conception of the main structures/algorithms would be sufficient in whatever traces it left. 34 | 35 | I had an overall positive experience with the architecture. There are still some kinks to work out, but my plan is to extract a super minimal library/framework from it to use in future projects. I partly want that for doing more games—but I'm also curious how it would extend to domains outside of games. 36 | 37 | ## Code Overview 38 | If you want to get to the meat of how the game itself works, it's all in [gameStateTransformer.js](https://github.com/westoncb/under-game/blob/master/js/gameStateTransformer.js) 39 | 40 | It uses [quadShaderCanvas.js](https://github.com/westoncb/under-game/blob/master/js/quadShaderCanvas.js) to set up three.js with a single rectangular Mesh using a ShaderMaterial, which is fit exactly to the canvas dimensions. All of the visuals are created by a fragment shader applied to that Mesh surface. 41 | 42 | The fragment shader is in [gameFragmentShader.js](https://github.com/westoncb/under-game/blob/master/js/gameFragmentShader.js). I've written a few of these now, but I'm still no pro. Expect some rookie mistakes. And I'd be glad for some optimization tips if anyone notices some easy changes that could be made... 43 | 44 | The cave shape generation is done in [caveGenerator.js](https://github.com/westoncb/under-game/blob/master/js/caveGenerator.js) 45 | 46 | The entry point to the code is in [index.js](https://github.com/westoncb/under-game/blob/master/js/index.js). It sets up the main update/render loop and initializes a Simulation object, telling it to use a GameStateTransformer. 47 | 48 | There are a few framework-ey classes which are the primary components of the 'architecture experiment' described above. They are: 49 | 50 | - [StateTransformer](https://github.com/westoncb/under-game/blob/master/js/stateTransformer.js) 51 | - [Simulation](https://github.com/westoncb/under-game/blob/master/js/simulation.js) 52 | - [Events](https://github.com/westoncb/under-game/blob/master/js/events.js) 53 | - [EvolveAid](https://github.com/westoncb/under-game/blob/master/js/evolveAid.js) 54 | 55 | **StateTransformer** is the core structure of the framework. The idea is that programs would be defined as a set of StateTransformers (potentially arranged in a hierarchy, but no use of that is made here—in fact this program only uses one real StateTransformer). And Each StateTransformer defines logic for transforming some state in response to a sequence of events and/or time passage. It will also likely _generate_ its own events when certain conditions are met, or in response to system input events. As an example, GameStateTransformer generates an event when the worm collides with the cave wall. 56 | 57 | **Simulation** is a special StateTransformer which does the actual work of triggering the methods defined by StateTransformers at the correct times. It is always active and manages some actual StateTransformer. 58 | 59 | **Events** is a simple queue. Events may be added to it like `Events.enqueue('event_name', eventData);`. Every frame/step while the app is running Simulation will remove events from the queue one at a time, passing them to the current StateTransformer via a call to `activeStateTransformer.handleEvent(event);`. 60 | 61 | **EvolveAid** EvolveAid makes 'transient state' and 'contingent evolvers' work (these are used by StateTranformers). Check out the documentation in evolveAid.js for more info. (Thinking about it, this probably should have just been a part of Simulation.) 62 | 63 | 64 | ## Build and Run 65 | ``` 66 | git clone https://github.com/westoncb/under-game 67 | cd under-game 68 | npm install 69 | ``` 70 | 71 | 72 | index.html loads `./build/bundle.js`. I wasn't sure about best practices for including build software in the package.json for an open source javascript project like this. I imagine you could build with whatever you prefer. I used Watchify personally, which you can set up like: 73 | 74 | ``` 75 | npm i watchify 76 | watchify ./js/index.js -o './build/bundle.js' 77 | ``` 78 | 79 | Then I serve the project with `http-server` (`npm i http-server`): 80 | ``` 81 | http-server -p 4000 82 | ``` 83 | 84 | Unfortunately, because of concerns I have about licensing issues with the sound files, they are not included in the repo; so when you run the game locally it will be silent unless you add your own sounds :/ 85 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westoncb/under-game/7273a71aada2839ca169fd2bbc1a118b002ed1a5/favicon.ico -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "threejs-starter", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "https://github.com/westoncb/threejs-starter", 6 | "author": "Weston C. Beecroft", 7 | "license": "MIT", 8 | "scripts": { 9 | "dev": "webpack-dev-server --mode development --devtool source-map", 10 | "build": "webpack --mode production" 11 | }, 12 | "dependencies": { 13 | "howler": "^2.0.15", 14 | "stats-js": "^1.0.0-alpha1", 15 | "three": "^0.137.5" 16 | }, 17 | "devDependencies": { 18 | "@babel/core": "^7.16.12", 19 | "@babel/preset-env": "^7.16.11", 20 | "babel-loader": "^8.2.3", 21 | "css-loader": "^6.5.1", 22 | "html-webpack-plugin": "^5.5.0", 23 | "style-loader": "^3.3.1", 24 | "webpack": "^5.67.0", 25 | "webpack-cli": "^4.9.2", 26 | "webpack-dev-server": "^4.7.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /plan.txt: -------------------------------------------------------------------------------- 1 | TODO: 2 | How to generate cave shape and render 3 | start with simple pink noise for shape 4 | generate in javascript, write height values for top and bottom to 1d texture 5 | shadow and cave edge/lip can be generated using those height vals as a distance func 6 | main texture of cave comes from noise: infrequent large stalagmite things get colored 7 | brightly beyond some height threshold; high frequency noise colore darkly when 8 | dropping beneath the surface by some threshold. 9 | 10 | Function (of x coord) that randomly distributes 'path points' 11 | Algo here can change: 12 | purely random 13 | sinusoidal 14 | square wave 15 | Long slopes up and long slopes down 16 | Function (of x coord) that returns a pair of path points: the one prior and after the current x pos 17 | Base path is uses those two points to get an actual line 18 | Cave 'carver' whose height is deterined by diffulty algo moves along path 19 | That 'carving' provides base heights for cave top/bottom, to which we add pink noise 20 | The above will be a continuous function of x, which we sample when forming the 1d tex 21 | 22 | How does the camera work (it's also kinematic, but has a follow target) 23 | Force vector on camera is proportional to diff between camera pos and player body center 24 | That force is capped at some maximum 25 | Additionally, the initial force calculation must take into account other forces acting on 26 | cam, so that the net force is proportional to diff 27 | There is a 'backward' force on the camera that behaves like friction: if stops accelerating 28 | forward it will come to a stop. 29 | The camera's size/pos are used to convert from world to screen coords (part of the glsl state build) 30 | 31 | How difficulty evolves in time 32 | How does game reset work 33 | Collision between player/coin and player/cave 34 | Player kinematics 35 | 36 | 37 | StateTransformer 38 | setUp() 39 | tearDown() 40 | handleEvent() 41 | update() 42 | render() 43 | 44 | Simulation extends StateTransformer 45 | setUp() {} 46 | tearDown() {} 47 | 48 | update(deltaTime) { 49 | While(!EventQueue.empty()) 50 | handleEvent(EventQueue.next()) 51 | 52 | subStateTransformer.update(deltaTime) 53 | } 54 | 55 | handleEvent(event) { 56 | // Handle 'transformer change' events by swapping the StateTransformer and calling setup/teardown 57 | 58 | subStateTransformer.handleEvent(event) 59 | } 60 | 61 | render() { 62 | 63 | subStateTransformer.render() 64 | } 65 | 66 | GameStateTransformer extends StateTransformer 67 | 68 | setUp() { 69 | this.state = { 70 | player: {position, velocity, mass, activeForces, dying, dead}, 71 | coins: [{position, dying, dead}], 72 | cave: {}, 73 | camera: {position, width, height, activeForces} 74 | score, 75 | } 76 | 77 | this.uniforms = {}; 78 | 79 | // Set up left/right/up key listeners which 80 | // emitt events like 'BOOST', "BRAKE", "ACCEL_UP" 81 | } 82 | 83 | update(deltaTime) { 84 | // update player accel/vol/position 85 | // update cave geometry 86 | // generate coins routine 87 | // general cleanup routine 88 | 89 | updateKinematics() 90 | 91 | findCollisions().forEach(col => EventQueue.push(collision event)) 92 | 93 | updateGLSLUniforms(this.state); 94 | } 95 | 96 | handleEvent(event) { 97 | // collision events, deathStart, deathFinish, boost, break, 98 | } 99 | 100 | updateKinematics() { 101 | const entities = [camera, player]; 102 | 103 | entities.forEach(entity => { 104 | // sum forces 105 | // use f = ma to determine acceleration 106 | // update velocity and position based on acceleration 107 | }); 108 | } 109 | 110 | updateGLSLUniforms(state) { 111 | 112 | } 113 | 114 | render { 115 | 116 | } 117 | 118 | CaveGenerator { 119 | constructor() { 120 | this.junctures = []; //(x,y) points each time path slope changes 121 | } 122 | 123 | getTopSurfaceY(x) { 124 | const noise = blah(x); 125 | 126 | return getBasicTopSurfaceY(x) + noise; 127 | } 128 | 129 | getBottomSurfaceY(x) { 130 | const noise = blah(x); 131 | 132 | return getBasicBottomSurfaceY(x) - noise; 133 | } 134 | 135 | getBasicTopSurfaceY(x) { 136 | const initialY = getPathY(x) + getApertureHeight(x); 137 | const overhang = max(initialY - maxY, 0); 138 | 139 | return initialY - overhang; 140 | } 141 | 142 | getBasicBottomSurfaceY(x) { 143 | return getBasicTopSurfaceY(x) - getApertureHeight(x); 144 | } 145 | 146 | getApertureHeight(x) { 147 | could event just be linear if we wanted to be simple 148 | } 149 | 150 | // These might also need to be functions of x like getApertureHeight(x) 151 | // minFlatLength, maxFlatLength, minSlantLength, maxSlantLength, minSlope, maxSlope 152 | 153 | getPathY(x) { 154 | Find `nextJuncture` via: 155 | Add up previous section lengths to see if x is in unexplored territory 156 | If so, generate a new juncture point 157 | Every other section is flat/slant, so base on index 158 | xPos is based on min/maxLength params 159 | yPos is based on a random slope via min/maxSlope params (and a global min/max Y) 160 | If not grab pre-generated juncture point 161 | 162 | Find the path height via linear interpolation from `previousJuncture` to `nextJuncture` 163 | } 164 | 165 | getSectionLength(x) { 166 | 167 | } 168 | } -------------------------------------------------------------------------------- /readme-media/anim.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westoncb/under-game/7273a71aada2839ca169fd2bbc1a118b002ed1a5/readme-media/anim.gif -------------------------------------------------------------------------------- /readme-media/screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westoncb/under-game/7273a71aada2839ca169fd2bbc1a118b002ed1a5/readme-media/screen.png -------------------------------------------------------------------------------- /src/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westoncb/under-game/7273a71aada2839ca169fd2bbc1a118b002ed1a5/src/.DS_Store -------------------------------------------------------------------------------- /src/appState.js: -------------------------------------------------------------------------------- 1 | class AppState {} 2 | 3 | AppState.canvasWidth = -1; 4 | AppState.canvasHeight = -1; 5 | 6 | module.exports = AppState; -------------------------------------------------------------------------------- /src/caveGenerator.js: -------------------------------------------------------------------------------- 1 | const Util = require('./util.js'); 2 | const AppState = require('./appState.js'); 3 | 4 | const THREE = require('three'); 5 | 6 | const MIN_APERTURE = 4; // meters; 7 | const MAX_APERTURE = 8.5; 8 | 9 | /* 10 | Used to generate the shape of the cave edges in the game, both for 11 | collision detection and rendering. It does this by providing two 12 | functions meant to be used externally: 13 | 14 | getTopSurfaceY(x) and getBottomSurfaceY(x) 15 | 16 | The idea is that for any X coordinate you pass in, it can give you the Y 17 | values of both the top and bottom of the cave at that location. 18 | 19 | The main algorithm is to generate a line running through the middle of 20 | the cave that has sloped and flat segments with random lengths. The 21 | points seperating these sections are called 'junctures'. Once we have 22 | a point on that line, we use the current 'aperture' size to figure 23 | out where the main surfaces of the top and bottom of the cave are. Then 24 | we add noise on top of this otherwise smooth surface. 25 | 26 | This deals exclusively in meters (i.e. not in pixels). 27 | 28 | TODO: It would probably improve the generated caves to also explicitly 29 | control: min/maxFlatLength, min/maxSlantLength, min/maxSlope 30 | and use those params in generateJuncture(...). They could vary by X in 31 | a similar manner to getApertureHeight(x). 32 | */ 33 | 34 | class CaveGenerator { 35 | constructor() { 36 | this.junctures = [new THREE.Vector2(-100, 5), new THREE.Vector2(100, 5)]; 37 | Util.newNoiseSeed(); 38 | } 39 | 40 | getTopSurfaceY(x) { 41 | const smoothTopSurface = this.getPathY(x) + this.getApertureHeight(x) / 2.; 42 | 43 | return smoothTopSurface + this.noise(x); 44 | } 45 | 46 | noise(x) { 47 | // If we don't phase in the noise, it's possible for it to overlap 48 | // the player's initial position. 49 | const introScale = Util.smoothstep(0, Util.toMeters(AppState.canvasWidth) * 2, x); 50 | 51 | const noise = (Util.noise1d(x / 3) ** 2 * 4) * Util.mix(0.65, 1.2, Util.smoothstep(MIN_APERTURE, MAX_APERTURE, this.getApertureHeight(x))) 52 | + Util.noise1d(x * 2.) / 2.5 53 | + Util.noise1d(x * 8.) / 7.; 54 | 55 | return noise * introScale; 56 | } 57 | 58 | getBottomSurfaceY(x) { 59 | return this.getTopSurfaceY(x) - this.getApertureHeight(x); 60 | } 61 | 62 | getApertureHeight(x) { 63 | const repeatDist = 100; 64 | const scaledX = x / (repeatDist / Math.PI); 65 | const noise = Util.noise1d(x / 5) * 2; 66 | const ratio = Util.smoothstep(0, repeatDist, (Math.sin(scaledX) + 1) / 2 * repeatDist * noise); 67 | return ((1 - ratio)*MAX_APERTURE + ratio*MIN_APERTURE); 68 | } 69 | 70 | getPathY(x) { 71 | const result = this.getPriorJunctureAndIndex(x); 72 | const priorJuncture = result.juncture; 73 | const followingJuncture = this.getFollowingJuncture(x, result.index); 74 | 75 | const pathProportion = (x - priorJuncture.x) / (followingJuncture.x - priorJuncture.x); 76 | const y = priorJuncture.y + (followingJuncture.y - priorJuncture.y) * pathProportion; 77 | 78 | return y; 79 | } 80 | 81 | getPriorJunctureAndIndex(x) { 82 | for (let i = this.junctures.length-1; i > -1; i--) { 83 | const juncture = this.junctures[i]; 84 | 85 | if (x >= juncture.x) { 86 | return {juncture, index: i}; 87 | } 88 | } 89 | 90 | console.error("Couldn't find prior juncture for x: ", x); 91 | } 92 | 93 | getFollowingJuncture(x, priorJunctureIndex) { 94 | let followingJuncture; 95 | 96 | for (let i = priorJunctureIndex + 1; i < this.junctures.length; i++) { 97 | const juncture = this.junctures[i]; 98 | if (x <= juncture.x) { 99 | return juncture 100 | } 101 | } 102 | 103 | const lastJunctureIndex = this.junctures.length - 1; 104 | const lastJuncture = this.junctures[lastJunctureIndex]; 105 | 106 | const newJuncture = this.generateJuncture(lastJuncture, lastJunctureIndex); 107 | this.junctures.push(newJuncture); 108 | 109 | return newJuncture; 110 | } 111 | 112 | generateJuncture(lastJuncture, index) { 113 | const length = Math.random() * 15 + 5; 114 | const angle = Math.random() * (Math.PI / 2) - (Math.PI / 4); 115 | const newJuncture = lastJuncture.clone().add(new THREE.Vector2(Math.cos(angle) * length, Math.sin(angle) * length)); 116 | 117 | return newJuncture; 118 | } 119 | } 120 | 121 | module.exports = CaveGenerator; -------------------------------------------------------------------------------- /src/events.js: -------------------------------------------------------------------------------- 1 | class Events { 2 | static enqueue(name, data) { 3 | Events.queue.push({name, data}); 4 | } 5 | 6 | static dequeue() { 7 | return Events.queue.shift(); 8 | } 9 | 10 | static empty() { 11 | return Events.queue.length === 0; 12 | } 13 | } 14 | Events.queue = [] 15 | 16 | module.exports = Events; -------------------------------------------------------------------------------- /src/evolveAid.js: -------------------------------------------------------------------------------- 1 | const Util = require('./util.js'); 2 | const Events = require('./events.js'); 3 | 4 | /* 5 | This is an experimental system for implementing a couple new 6 | architectural ideas: 7 | 8 | 1) Transient state: pieces of state that will only exist for a limited 9 | duration, and which emit an event after that duration. These are 10 | set up using runTransientState(...) 11 | 12 | 2) Contingent evolvers: these are used to specify state evolution logic 13 | that should only occur under certain conditions. EvolveAid is constructed 14 | with an array of these contingent evolvers; the only requirements on them 15 | is that they defined two methods: condition(state) and evolve(state, deltaTime). 16 | The first determines whether to execute the second on a given step of the program. 17 | */ 18 | 19 | class EvolveAid { 20 | constructor(state, contingentEvolvers = []) { 21 | this.state = state; 22 | this.contingentEvolvers = contingentEvolvers; 23 | this.transientStatePaths = []; 24 | this.lastTime = 0; 25 | } 26 | 27 | update(time, deltaTime) { 28 | const state = this.state; 29 | 30 | this.contingentEvolvers.filter(evolver => evolver.condition(state)). 31 | forEach(evolver => evolver.evolve(state, deltaTime)); 32 | 33 | const pathsToRemove = []; 34 | this.transientStatePaths.forEach(path => { 35 | const prop = Util.getPropAtPath(state, path); 36 | if (prop.transient) { 37 | const transState = prop; 38 | const timeSoFar = time - transState.startTime; 39 | transState.completion = timeSoFar / transState.duration; 40 | 41 | if (transState.completion >= 1) { 42 | delete state[path]; 43 | pathsToRemove.push(path); 44 | Events.enqueue(path + "_finished", transState); 45 | } 46 | } 47 | }); 48 | 49 | pathsToRemove.forEach(path => { 50 | const index = this.transientStatePaths. 51 | findIndex(currentPath => currentPath === path); 52 | this.transientStatePaths.splice(index, 1); 53 | }); 54 | 55 | this.lastTime = time; 56 | } 57 | 58 | runTransientState(propertyPath, subState, duration) { 59 | const state = this.state; 60 | 61 | const transState = {startTime: this.lastTime, duration, completion: 0, transient: true}; 62 | Util.objSpreadInto(subState, transState); 63 | Util.setPropAtPath(state, propertyPath, transState); 64 | 65 | this.transientStatePaths.push(propertyPath); 66 | } 67 | } 68 | 69 | module.exports = EvolveAid; -------------------------------------------------------------------------------- /src/gameFragmentShader.js: -------------------------------------------------------------------------------- 1 | class GameFragmentShader { 2 | static getText() { 3 | return ` 4 | 5 | precision highp float; 6 | 7 | uniform vec2 resolution; 8 | uniform float aspectRatio; 9 | uniform vec2 cameraPos; 10 | uniform float time; 11 | uniform mat4 wormData; 12 | uniform mat4 wormData2; 13 | uniform sampler2D topHeights; 14 | uniform sampler2D bottomHeights; 15 | uniform float wormDeathRebirthRatio; 16 | uniform float bgDeathRebirthRatio; 17 | uniform float caveShutDeathRebirthRatio; 18 | uniform float cavePatternDeathRebirthRatio; 19 | uniform float resetTransitionRatio; 20 | uniform float pointZoneHeight; 21 | uniform float pointZoneIntensity; 22 | 23 | #define PI 3.14159265 24 | 25 | // Similar to fOpUnionRound, but more lipschitz-y at acute angles 26 | // (and less so at 90 degrees). Useful when fudging around too much 27 | // by MediaMolecule, from Alex Evans' siggraph slides 28 | // http://mercury.sexy/hg_sdf/ 29 | float fOpUnionSoft(float a, float b, float r) { 30 | float e = max(r - abs(a - b), 0.); 31 | return min(a, b) - e*e*0.25/r; 32 | } 33 | 34 | // https://www.shadertoy.com/view/Msf3WH 35 | vec2 hash( vec2 p ) { 36 | p = vec2( dot(p,vec2(127.1,311.7)), 37 | dot(p,vec2(269.5,183.3)) ); 38 | 39 | return -1.0 + 2.0*fract(sin(p)*43758.5453123); 40 | } 41 | 42 | // Simplex noise from https://www.shadertoy.com/view/Msf3WH 43 | float noise( in vec2 p ) { 44 | const float K1 = 0.366025404; // (sqrt(3)-1)/2; 45 | const float K2 = 0.211324865; // (3-sqrt(3))/6; 46 | 47 | vec2 i = floor( p + (p.x+p.y)*K1 ); 48 | 49 | vec2 a = p - i + (i.x+i.y)*K2; 50 | vec2 o = step(a.yx,a.xy); 51 | vec2 b = a - o + K2; 52 | vec2 c = a - 1.0 + 2.0*K2; 53 | 54 | vec3 h = max( 0.5-vec3(dot(a,a), dot(b,b), dot(c,c) ), 0.0 ); 55 | 56 | vec3 n = h*h*h*h*vec3( dot(a,hash(i+0.0)), dot(b,hash(i+o)), dot(c,hash(i+1.0))); 57 | 58 | return dot( n, vec3(70.0) ); 59 | } 60 | 61 | // unsigned round box 62 | // http://mercury.sexy/hg_sdf/ 63 | float udRoundBox( vec2 p, vec2 b, float r ) 64 | { 65 | return length(max(abs(p)-b, 0.0))-r; 66 | } 67 | 68 | // https://www.shadertoy.com/view/Msf3WH 69 | float fractalNoise(vec2 uv) { 70 | float f = 0.; 71 | mat2 m = mat2( 1.6, 1.2, -1.2, 1.6 ); 72 | f = 0.5000*noise( uv ); uv = m*uv; 73 | f += 0.2500*noise( uv ); uv = m*uv; 74 | f += 0.1250*noise( uv ); uv = m*uv; 75 | f += 0.0625*noise( uv ); uv = m*uv; 76 | 77 | return f; 78 | } 79 | 80 | //From http://mercury.sexy/hg_sdf/ 81 | //Repeat only a few times: from indices to (similar to above, but more flexible) 82 | float pModInterval1(inout float p, float size, float start, float stop) { 83 | float halfsize = size*0.5; 84 | float c = floor((p + halfsize)/size); 85 | p = mod(p+halfsize, size) - halfsize; 86 | if (c > stop) { //yes, this might not be the best thing numerically. 87 | p += size*(c - stop); 88 | c = stop; 89 | } 90 | if (c < start) { 91 | p += size*(c - start); 92 | c = start; 93 | } 94 | return c; 95 | } 96 | 97 | vec4 bgColor(vec2 uv) { 98 | vec2 modifiedUV = uv + cameraPos * 0.25; 99 | float noise1 = noise(modifiedUV * 5.)*noise(modifiedUV * 50.) / 2.; 100 | 101 | float r = noise1 + noise1/3. * (1. + bgDeathRebirthRatio); 102 | float g = noise1 / 2.; 103 | float b = noise1 * 2.2 + ((sin(time / 2.) + 1.) / 2.) * 0.05; 104 | 105 | g = g + -g * bgDeathRebirthRatio; 106 | b = b + -b * bgDeathRebirthRatio; 107 | 108 | return vec4(r, g, b, 1.0); 109 | } 110 | 111 | float wormDistance(vec2 uv) { 112 | float sideLength = 0.028; 113 | float cornerRadius = 0.014; 114 | vec2 boxSize = vec2(sideLength, sideLength); 115 | 116 | float angle = wormDeathRebirthRatio * PI / 4.; 117 | float x = noise(uv) * sin(angle); 118 | float y = noise(uv * cos(angle) * 30.); 119 | vec2 deathAnimOffset = vec2(x, y) * 20. * wormDeathRebirthRatio; 120 | 121 | uv += deathAnimOffset; 122 | 123 | float dist1 = udRoundBox(uv - wormData[0].xy, boxSize, cornerRadius); 124 | float dist2 = udRoundBox(uv - wormData[1].xy, boxSize, cornerRadius); 125 | float dist3 = udRoundBox(uv - wormData[2].xy, boxSize, cornerRadius); 126 | float dist4 = udRoundBox(uv - wormData[3].xy, boxSize, cornerRadius); 127 | float dist5 = udRoundBox(uv - wormData2[0].xy, boxSize, cornerRadius); 128 | float dist6 = udRoundBox(uv - wormData2[1].xy, boxSize, cornerRadius); 129 | 130 | float r = 0.078; 131 | 132 | float wormDataUnion = fOpUnionSoft(fOpUnionSoft(fOpUnionSoft(dist1, dist2, r), dist3, r), dist4, r); 133 | float wormData2Union = fOpUnionSoft(dist5, dist6, r); 134 | 135 | float dist = fOpUnionSoft(wormDataUnion, wormData2Union, r) / (sideLength + cornerRadius) - 0.3; 136 | 137 | return dist; 138 | } 139 | 140 | vec4 getWormColor(float dist, vec2 uv) { 141 | dist += 0.3 + (pointZoneIntensity * 0.1); 142 | 143 | float borderMod = smoothstep(0.1, 0.3, dist) * 3.5; 144 | float brighten = abs(-dist / 1.5) * (1. + pointZoneIntensity); 145 | 146 | float r = brighten + (borderMod * (pointZoneIntensity + 0.1)); 147 | float g = brighten - (borderMod * pointZoneIntensity); 148 | float b = (cos(time) + 1.) * 0.2 + brighten*2. + borderMod - (borderMod * pointZoneIntensity); 149 | 150 | float c = noise((uv + cameraPos * .25) * 10.) / 2.; 151 | 152 | g += c * 0.4; 153 | b += c * 0.8 + wormDeathRebirthRatio; 154 | 155 | return vec4(r, g, b, 1.) * smoothstep(0.3, 0.2, dist); 156 | } 157 | 158 | vec4 getCaveWallColor(float dist, vec2 p) { 159 | float negDist = -dist; 160 | 161 | float glow = (1. - smoothstep(0., .04, negDist)) * 0.8; 162 | 163 | // Fade out glow during death 164 | glow += -glow * cavePatternDeathRebirthRatio; 165 | 166 | float resetScrollBack = smoothstep(0., 0.5, resetTransitionRatio) * cameraPos.x; 167 | float animationFactor = 1.08; 168 | float noise1 = fractalNoise(p + vec2(cameraPos.x / aspectRatio * animationFactor - resetScrollBack, cameraPos.y)) * 3.5; 169 | float steppedNoise = noise1 * (1. - smoothstep(0., .04, negDist)); 170 | 171 | // Fade out noise glow during death 172 | steppedNoise += -steppedNoise * cavePatternDeathRebirthRatio; 173 | 174 | float deathAnimScale = (1. - cavePatternDeathRebirthRatio / 2.5); 175 | float distWithNoise = negDist + noise1 * deathAnimScale; 176 | 177 | // This is the main thing responsible for the cool fractal thing 178 | // decorating the cave 179 | float noise2 = noise(vec2(0., pModInterval1(distWithNoise, 0.05, 0., 13.))) / 4.; 180 | 181 | float r = 0.4 - (glow * 0.2) + noise2 * 0.5; 182 | float g = r; 183 | float b = (glow + steppedNoise) / 3. + noise2 + 0.6; 184 | 185 | return vec4(r, g, b, 1.0); 186 | } 187 | 188 | float caveDistance(vec2 uv, vec2 p) { 189 | float topHeight = texture2D(topHeights, vec2(p.x, 0.)).r; 190 | float bottomHeight = texture2D(bottomHeights, vec2(p.x, 0.)).r; 191 | 192 | float caveShutDistance = caveShutDeathRebirthRatio * (topHeight - bottomHeight)/2.; 193 | float topDist = topHeight - caveShutDistance - uv.y; 194 | float bottomDist = uv.y - (bottomHeight + caveShutDistance); 195 | 196 | return min(topDist, bottomDist); 197 | } 198 | 199 | void main(void) { 200 | vec2 p = gl_FragCoord.xy / resolution.xy; 201 | vec2 uv = p * vec2(aspectRatio, 1.0); 202 | 203 | 204 | float caveDist = caveDistance(uv, p); 205 | if (caveDist < 0.) { 206 | 207 | gl_FragColor = getCaveWallColor(caveDist, p); 208 | return; 209 | } 210 | 211 | float wormDist = wormDistance(uv); 212 | if (wormDist < 0.) { 213 | 214 | gl_FragColor = getWormColor(wormDist, uv); 215 | 216 | } else { // background + point-zone flame 217 | 218 | float inPointZone = step(caveDist - pointZoneHeight, 0.) * step(wormDist - 7., 0.); 219 | 220 | float modPointZoneHeight = pointZoneHeight * (noise(uv * 5.) + 1.) / 2.; 221 | float diff = modPointZoneHeight - caveDist; 222 | float heightFactor = smoothstep(0.075, 1., diff / modPointZoneHeight); 223 | 224 | float flameR = 0.2 + heightFactor * 2.; 225 | float flameB = 0.5 - heightFactor / 4.; 226 | float flameA = heightFactor * smoothstep(5., 2., wormDist) * inPointZone; 227 | 228 | vec4 flameVec = vec4((flameR + flameA / 5.), 0., (flameB + flameA / 2.), 0.) * flameA; 229 | 230 | gl_FragColor = bgColor(uv) + flameVec; 231 | } 232 | } 233 | ` 234 | } 235 | } 236 | 237 | module.exports = GameFragmentShader 238 | -------------------------------------------------------------------------------- /src/gameStateTransformer.js: -------------------------------------------------------------------------------- 1 | const QuadShaderCanvas = require("./quadShaderCanvas.js") 2 | const StateTransformer = require("./StateTransformer.js") 3 | const CaveGenerator = require("./caveGenerator.js") 4 | const AppState = require("./appState.js") 5 | const Util = require("./util.js") 6 | const Events = require("./events.js") 7 | const EvolveAid = require("./evolveAid.js") 8 | const GameFragmentShader = require("./gameFragmentShader.js") 9 | 10 | const THREE = require("three") 11 | const Vector2 = THREE.Vector2 12 | const { Howl, Howler } = require("howler") 13 | 14 | const Y_HISTORY_LENGTH = 5000 15 | const CAVE_SAMPLE_DIST = 4 16 | const WORM_BLOCK_SPACING = 0.8 17 | const BASE_POINTS_PER_SEC = 3 18 | 19 | // These are constructed here for performance reasons. 20 | // Probably not actually worthwhile except maybe 21 | // CAVE_TOP/BOTTOM_VEC. 22 | const GRAVITY_VEC = new Vector2() 23 | const WORM_FORWARD_VEC = new Vector2() 24 | const WORM_UP_VEC = new Vector2() 25 | const TOTAL_FORCE_VEC = new Vector2() 26 | const ACCEL_VEC = new Vector2() 27 | const CAMERA_SHIFT_VEC = new Vector2() 28 | const CAVE_TOP_VEC = new Vector2() 29 | const CAVE_BOTTOM_VEC = new Vector2() 30 | 31 | /* 32 | This is where all the gameplay logic and input handling takes places. 33 | This works mostly by making modifications to the 'state' object, and 34 | then rendering key parts of that state via a fragment shader. The 35 | mapping of state to rendering data takes place in mapStateToUniforms(...). 36 | 37 | The units used internally here are meters. 38 | */ 39 | 40 | class GameStateTransformer extends StateTransformer { 41 | // Called by Simulation 42 | setUp() { 43 | // This is a utility for setting up a full-canvas quad with 44 | // a shader material applied to it 45 | this.quadShaderCanvas = new QuadShaderCanvas( 46 | "canvas-container", 47 | GameFragmentShader.getText(), 48 | { resizeHandler: this.resizeOccurred.bind(this), showStats: false } 49 | ) 50 | this.caveGenerator = new CaveGenerator() 51 | 52 | this.setUpBrowserInputHandlers() 53 | 54 | this.loadSounds() 55 | 56 | // We communicate the cave shape to the fragment shader 57 | // via two 1d textures created here. 58 | this.createCaveDataTextures() 59 | 60 | this.reset() 61 | } 62 | 63 | reset() { 64 | this.initState() 65 | this.initUniforms() 66 | this.initEvolveAid() 67 | 68 | this.birthSound.play() 69 | } 70 | 71 | // Called by Simulation 72 | update(time, deltaTime) { 73 | const state = this.state 74 | 75 | if (this.focused) { 76 | this.assignEnvironmentalForces() 77 | 78 | this.updateKinematics(deltaTime) 79 | 80 | this.runEnvironmentalEventGenerators() 81 | 82 | this.updateCaveGeometry() 83 | 84 | this.updateWormHistory() 85 | 86 | this.evolveAid.update(time, deltaTime) 87 | 88 | state.gameTime += deltaTime 89 | state.time = time 90 | 91 | this.mapStateToUniforms(state) 92 | } 93 | } 94 | 95 | initState() { 96 | const state = { 97 | time: 0, 98 | worm: { 99 | position: this.getInitialWormPosition(), 100 | velocity: new Vector2(), 101 | rotation: 0, 102 | mass: 40, 103 | activeForces: [], 104 | velocityCap: new Vector2(6.5, 10), 105 | collisionBounds: { width: 0.4, height: 0.4 }, // meters 106 | }, 107 | camera: { position: this.getInitialWormPosition() }, 108 | keyStates: {}, // Keyboard keys 109 | gameTime: 0, 110 | wormYHistory: [], 111 | yHistoryIndex: 0, 112 | timeInZone: 0, 113 | timeOutOfZone: 0, 114 | inZone: false, 115 | pointZoneIntensity: 0, 116 | points: 0, 117 | } 118 | 119 | state.worm.ignoreKinematics = () => 120 | this.state.worm.dying || this.state.resetTransition 121 | 122 | this.state = state 123 | } 124 | 125 | initUniforms() { 126 | const state = this.state 127 | const uniforms = this.quadShaderCanvas.uniforms 128 | 129 | uniforms.topHeights = { type: "t", value: this.topHeightTex } 130 | uniforms.bottomHeights = { type: "t", value: this.bottomHeightTex } 131 | uniforms.wormDeathRebirthRatio = { value: 0 } 132 | uniforms.bgDeathRebirthRatio = { value: 0 } 133 | uniforms.caveShutDeathRebirthRatio = { value: 0 } 134 | uniforms.cavePatternDeathRebirthRatio = { value: 0 } 135 | uniforms.resetTransitionRatio = { value: 0 } 136 | uniforms.cameraPos = { value: state.camera.position } 137 | uniforms.wormData = { value: new Float32Array(16) } 138 | uniforms.wormData2 = { value: new Float32Array(16) } 139 | uniforms.inZone = { value: 0 } 140 | uniforms.pointZoneHeight = { value: 0 } 141 | uniforms.pointZoneIntensity = { value: 0 } 142 | } 143 | 144 | initEvolveAid() { 145 | // See evolveAid.js for more info on how these work. 146 | // Also, yes, the way I'm actually using them at the 147 | // moment is basically pointless—but imagine! 148 | this.contingentEvolvers = [ 149 | { 150 | condition: state => state.inZone, 151 | evolve: (state, deltaTime) => { 152 | state.timeInZone += deltaTime 153 | state.pointZoneIntensity = Math.min( 154 | state.pointZoneIntensity + deltaTime / 3, 155 | 1 156 | ) 157 | state.points += 158 | deltaTime * 159 | Math.pow(state.pointZoneIntensity * 5 + 1, 2) 160 | }, 161 | }, 162 | { 163 | condition: state => !state.inZone, 164 | evolve: (state, deltaTime) => { 165 | state.timeOutOfZone += deltaTime 166 | state.pointZoneIntensity = Math.max( 167 | state.pointZoneIntensity - deltaTime, 168 | 0 169 | ) 170 | }, 171 | }, 172 | { 173 | condition: state => !state.worm.dying, 174 | evolve: (state, deltaTime) => { 175 | state.points += BASE_POINTS_PER_SEC * deltaTime 176 | this.updateCameraPosition(state) 177 | this.pointZoneSound.volume(state.pointZoneIntensity) 178 | }, 179 | }, 180 | ] 181 | 182 | this.evolveAid = new EvolveAid(this.state, this.contingentEvolvers) 183 | } 184 | 185 | // Called by Simulation 186 | handleEvent(event) { 187 | const state = this.state 188 | 189 | if (event.name === "worm_cave_collision") { 190 | state.inZone = false 191 | state.pointZoneIntensity = 0 192 | state.timeInZone = 0 193 | this.evolveAid.runTransientState( 194 | "worm.dying", 195 | { position: event.data.wormPosition }, 196 | 1.5 197 | ) 198 | 199 | this.pointZoneSound.stop() 200 | this.deathSound.play() 201 | setTimeout(() => this.caveShutSound.play(), 1150) 202 | } else if (event.name === "worm.dying_finished") { 203 | this.evolveAid.runTransientState("resetTransition", {}, 1.5) 204 | 205 | // This is a little trick to regenerate the cave shape using a 206 | // new seed while in the middle of the 'resetTransition' animation. 207 | setTimeout(() => { 208 | this.caveGenerator = new CaveGenerator() 209 | state.worm.position = this.getInitialWormPosition() 210 | state.camera.position = this.getInitialWormPosition() 211 | }, 400) 212 | 213 | setTimeout(() => this.caveOpenSound.play(), 1000) 214 | } else if (event.name === "resetTransition_finished") { 215 | this.reset() 216 | } else if (event.name === "point_zone_entry") { 217 | state.timeOutOfZone = 0 218 | state.timeInZone = 0 219 | if (!state.worm.dying) { 220 | state.inZone = true 221 | } 222 | 223 | if (!this.pointZoneSound.playing() && !state.worm.dying) { 224 | this.pointZoneSound.play() 225 | } 226 | } else if (event.name === "point_zone_exit") { 227 | state.timeInZone = 0 228 | state.timeOutOfZone = 0 229 | state.inZone = false 230 | } 231 | } 232 | 233 | updateKinematics(deltaTime) { 234 | // The initial plan involved a lot more kinematical objects... 235 | const entities = [this.state.worm] 236 | 237 | entities.forEach(entity => { 238 | if (!entity.ignoreKinematics()) { 239 | const totalForce = TOTAL_FORCE_VEC.set(0, 0, 0) 240 | entity.activeForces.forEach(force => { 241 | totalForce.add(force) 242 | }) 243 | 244 | const acceleration = ACCEL_VEC.set( 245 | totalForce.x / entity.mass, 246 | totalForce.y / entity.mass 247 | ) 248 | const velocity = entity.velocity 249 | 250 | velocity.addScaledVector(acceleration, deltaTime) 251 | 252 | // Cap velocity 253 | velocity.x = 254 | Math.min( 255 | Math.abs(velocity.x), 256 | Math.abs(entity.velocityCap.x) 257 | ) * Math.sign(velocity.x) 258 | velocity.y = 259 | Math.min( 260 | Math.abs(velocity.y), 261 | Math.abs(entity.velocityCap.y) 262 | ) * Math.sign(velocity.y) 263 | 264 | entity.position.addScaledVector(velocity, deltaTime) 265 | } 266 | 267 | entity.activeForces.length = 0 268 | }) 269 | } 270 | 271 | updateCameraPosition(state) { 272 | const camera = state.camera 273 | const worm = state.worm 274 | 275 | const target = worm.position.clone() 276 | target.x += 5 277 | camera.position.addScaledVector(target.sub(camera.position), 1 / 10) 278 | } 279 | 280 | // All the worm segments aside from the 'head' segment exactly follow 281 | // the Y-positions which the head passed through. 282 | updateWormHistory() { 283 | const state = this.state 284 | const wormPos = state.worm.position 285 | const newYHistoryIndex = 286 | Math.floor(Util.toPixels(wormPos.x)) % Y_HISTORY_LENGTH 287 | 288 | if (state.yHistoryIndex > newYHistoryIndex) { 289 | for (let i = state.yHistoryIndex + 1; i < Y_HISTORY_LENGTH; i++) { 290 | state.wormYHistory[i] = wormPos.y 291 | } 292 | for (let i = 0; i <= newYHistoryIndex; i++) { 293 | state.wormYHistory[i] = wormPos.y 294 | } 295 | } else { 296 | for (let i = state.yHistoryIndex + 1; i <= newYHistoryIndex; i++) { 297 | state.wormYHistory[i] = wormPos.y 298 | } 299 | } 300 | state.yHistoryIndex = newYHistoryIndex 301 | } 302 | 303 | assignEnvironmentalForces() { 304 | const worm = this.state.worm 305 | 306 | const gravityConstant = 6.673e-11 307 | const earthMass = 5.98e24 308 | const earthRadius = 6.38e6 309 | const gravityForceMagnitude = 310 | (gravityConstant * earthMass * worm.mass) / 6.38e6 ** 2 311 | 312 | // Weaken gravity and upward thrust for the first few seconds 313 | const introScale = Util.smoothstep(0, 2.5, this.state.gameTime) 314 | 315 | // Gravity 316 | worm.activeForces.push( 317 | GRAVITY_VEC.set(0, -gravityForceMagnitude * introScale) 318 | ) 319 | 320 | // Worm forward thrust 321 | worm.activeForces.push(WORM_FORWARD_VEC.set(100, 0)) 322 | 323 | // Worm upward thrust 324 | if (this.state.keyStates.ArrowUp) { 325 | worm.activeForces.push(WORM_UP_VEC.set(0, 1000 * introScale)) 326 | } 327 | } 328 | 329 | /* 330 | Read the cave edge heights from the CaveGenerator for every currently 331 | visible pixel on the screen. Write those heights to 1d texures to 332 | pass to the fragment shader. 333 | */ 334 | updateCaveGeometry() { 335 | const camPosInPixels = Util.toPixelsV(this.state.camera.position) 336 | const camX = 337 | Math.floor(Util.toPixels(this.state.camera.position.x)) - 338 | Math.floor(AppState.canvasWidth / 2) 339 | const texelCount = AppState.canvasWidth / CAVE_SAMPLE_DIST 340 | 341 | for (let i = 0; i < texelCount; i++) { 342 | const xPixelInMeters = Util.toMeters(i * CAVE_SAMPLE_DIST + camX) 343 | 344 | const topY = this.caveGenerator.getTopSurfaceY(xPixelInMeters) 345 | this.topHeightTex.image.data[i] = this.cameraTransform( 346 | CAVE_TOP_VEC.set(0, topY), 347 | camPosInPixels 348 | ).y 349 | 350 | const bottomY = this.caveGenerator.getBottomSurfaceY(xPixelInMeters) 351 | this.bottomHeightTex.image.data[i] = this.cameraTransform( 352 | CAVE_BOTTOM_VEC.set(0, bottomY), 353 | camPosInPixels 354 | ).y 355 | } 356 | 357 | this.topHeightTex.needsUpdate = true 358 | this.bottomHeightTex.needsUpdate = true 359 | } 360 | 361 | /* 362 | Map the game state to variables passed into the fragment 363 | shader for rendering. 364 | */ 365 | mapStateToUniforms(state) { 366 | const uniforms = this.quadShaderCanvas.uniforms 367 | const aspectRatio = AppState.canvasWidth / AppState.canvasHeight 368 | const worm = state.worm 369 | 370 | const wormPos = this.cameraTransform(worm.position.clone()) 371 | 372 | const cameraPos = Util.toPixelsV(state.camera.position) 373 | cameraPos.x /= AppState.canvasWidth 374 | cameraPos.x *= aspectRatio 375 | cameraPos.y /= AppState.canvasHeight 376 | 377 | uniforms.time.value = state.time 378 | uniforms.cameraPos.value = cameraPos 379 | 380 | if (state.resetTransition) 381 | uniforms.resetTransitionRatio.value = 382 | state.resetTransition.completion 383 | 384 | uniforms.pointZoneIntensity.value = Util.smoothstep( 385 | 0.1, 386 | 1, 387 | state.pointZoneIntensity 388 | ) 389 | uniforms.pointZoneHeight.value = 390 | (Util.toPixels(this.getPointZoneHeight(state.timeInZone)) / 391 | AppState.canvasHeight) * 392 | (1 / aspectRatio) * 393 | uniforms.pointZoneIntensity.value 394 | 395 | const wormDeathRatio = worm.dying ? worm.dying.completion : 0 396 | const resetTransitionRatio = uniforms.resetTransitionRatio.value 397 | 398 | // These are all timed-event completion ratios for effects that play forward for death 399 | // then in reverse for rebirth. 400 | uniforms.wormDeathRebirthRatio.value = 401 | Util.smoothstep(0, 0.35, wormDeathRatio) - 402 | Util.smoothstep(0.75, 1, resetTransitionRatio) 403 | uniforms.bgDeathRebirthRatio.value = 404 | Util.smoothstep(0, 0.4, wormDeathRatio) - resetTransitionRatio 405 | uniforms.caveShutDeathRebirthRatio.value = 406 | Util.smoothstep(0, 0.4, Math.pow(wormDeathRatio, 4)) - 407 | Util.smoothstep(0.5, 1, resetTransitionRatio) 408 | uniforms.cavePatternDeathRebirthRatio.value = 409 | wormDeathRatio - 410 | Util.smoothstep(0.5, 1, Math.pow(resetTransitionRatio, 2)) 411 | 412 | // Update trailing worm block positions 413 | // and copy into matrix uniforms 414 | for (let i = 0; i < 6; i++) { 415 | const rotation = worm.velocity.clone().normalize().angle() 416 | 417 | const wormPosClone = worm.position.clone() 418 | wormPosClone.x += -WORM_BLOCK_SPACING * i 419 | wormPosClone.y = this.getPastWormY(wormPosClone.x) 420 | this.setWormPartData( 421 | this.cameraTransform(wormPosClone), 422 | rotation, 423 | i 424 | ) 425 | } 426 | } 427 | 428 | setWormPartData(position, rotation, index) { 429 | if (index < 4) { 430 | const i = index * 4 431 | const wormData = this.quadShaderCanvas.uniforms.wormData 432 | wormData.value[i + 0] = position.x 433 | wormData.value[i + 1] = position.y 434 | wormData.value[i + 2] = rotation 435 | } else { 436 | const i = (index - 4) * 4 437 | const wormData = this.quadShaderCanvas.uniforms.wormData2 438 | wormData.value[i + 0] = position.x 439 | wormData.value[i + 1] = position.y 440 | wormData.value[i + 2] = rotation 441 | } 442 | } 443 | 444 | cameraTransform(vec, camPosInPixels) { 445 | // `camPosInPixels` can be provided optionally 446 | // for performance reasons 447 | if (!camPosInPixels) { 448 | camPosInPixels = Util.toPixelsV(this.state.camera.position) 449 | } 450 | 451 | const camShift = CAMERA_SHIFT_VEC.set( 452 | -camPosInPixels.x + AppState.canvasWidth / 2, 453 | -camPosInPixels.y + AppState.canvasHeight / 2 454 | ) 455 | const transformedVec = Util.toPixelsVModify(vec).add(camShift) 456 | transformedVec.x /= AppState.canvasWidth 457 | transformedVec.x *= AppState.canvasWidth / AppState.canvasHeight 458 | transformedVec.y /= AppState.canvasHeight 459 | 460 | return transformedVec 461 | } 462 | 463 | // Previous y position of worm head segment center in pixels 464 | getPastWormY(x) { 465 | const i = Math.floor(Util.toPixels(x)) % Y_HISTORY_LENGTH 466 | const y = this.state.wormYHistory[i] 467 | 468 | if (isNaN(y)) { 469 | return this.getInitialWormPosition().y 470 | } else { 471 | return y 472 | } 473 | } 474 | 475 | runEnvironmentalEventGenerators() { 476 | const state = this.state 477 | 478 | this.detectWormCaveCollision(state) 479 | this.detectPointZoneEvents(state) 480 | } 481 | 482 | detectWormCaveCollision(state) { 483 | const worm = state.worm 484 | 485 | if (worm.dying) { 486 | return 487 | } 488 | 489 | const testPoints = this.getWormCollisionTestPoints(worm) 490 | for (let i = 0; i < testPoints.length; i++) { 491 | const testPoint = testPoints[i] 492 | const caveTopY = this.caveGenerator.getTopSurfaceY(testPoint.x) 493 | const caveBottomY = this.caveGenerator.getBottomSurfaceY( 494 | testPoint.x 495 | ) 496 | 497 | if (testPoint.y < caveBottomY || testPoint.y > caveTopY) { 498 | const wormPosition = worm.position.clone() 499 | Events.enqueue("worm_cave_collision", { 500 | collisionPoint: testPoint, 501 | wormPosition, 502 | }) 503 | 504 | return 505 | } 506 | } 507 | } 508 | 509 | detectPointZoneEvents(state) { 510 | const testPoints = this.getWormCollisionTestPoints(state.worm) 511 | let testPointsInZone = 0 512 | 513 | for (let i = 0; i < testPoints.length; i++) { 514 | const testPoint = testPoints[i] 515 | const pointZoneHeight = this.getPointZoneHeight(state.timeInZone) 516 | const pointZoneTopY = 517 | this.caveGenerator.getTopSurfaceY(testPoint.x) - pointZoneHeight 518 | const pointZoneBottomY = 519 | this.caveGenerator.getBottomSurfaceY(testPoint.x) + 520 | pointZoneHeight 521 | 522 | if (testPoint.y < pointZoneBottomY || testPoint.y > pointZoneTopY) { 523 | if (!state.inZone) { 524 | Events.enqueue("point_zone_entry", {}) 525 | } 526 | 527 | testPointsInZone++ 528 | } 529 | } 530 | 531 | if (testPointsInZone === 0 && state.inZone) { 532 | Events.enqueue("point_zone_exit", {}) 533 | } 534 | } 535 | 536 | /* 537 | Grabs some points along the top and bottom surface of 538 | the worm head to use in collision testing. 539 | */ 540 | getWormCollisionTestPoints(worm) { 541 | const bounds = worm.collisionBounds 542 | const wormTopY = worm.position.y + bounds.height / 2 543 | const wormBottomY = worm.position.y - bounds.height / 2 544 | const startX = worm.position.x - bounds.width / 2 545 | const finishX = worm.position.x + bounds.width / 2 546 | const samples = 3 547 | const increment = (finishX - startX) / (samples - 1) 548 | 549 | const points = [] 550 | for (let i = 0; i < samples; i++) { 551 | const wormSampleX = startX + increment * i 552 | 553 | points.push(new Vector2(wormSampleX, wormTopY)) 554 | points.push(new Vector2(wormSampleX, wormBottomY)) 555 | } 556 | 557 | return points 558 | } 559 | 560 | getPointZoneHeight(timeInZone) { 561 | return Util.mix(1.5, 2.5, Util.smoothstep(0, 3, timeInZone)) 562 | } 563 | 564 | // Called by Simulation 565 | render() { 566 | if (this.focused) { 567 | this.quadShaderCanvas.render() 568 | } 569 | 570 | this.updatePointDisplay() 571 | } 572 | 573 | getCaveDataTexture() { 574 | const width = AppState.canvasWidth / CAVE_SAMPLE_DIST 575 | 576 | return new THREE.DataTexture( 577 | new Float32Array(width), 578 | width, 579 | 1, 580 | THREE.RedFormat, 581 | THREE.FloatType, 582 | THREE.UVMapping, 583 | THREE.ClampWrapping, 584 | THREE.ClampWrapping, 585 | THREE.LinearFilter, 586 | THREE.LinearFilter, 587 | 1, 588 | THREE.LinearEncoding 589 | ) 590 | } 591 | 592 | getInitialWormPosition() { 593 | const x = Util.toMeters(AppState.canvasWidth * 0.1) 594 | const y = 595 | (this.caveGenerator.getTopSurfaceY(x) + 596 | this.caveGenerator.getBottomSurfaceY(x)) / 597 | 2 598 | 599 | return new Vector2(x, y) 600 | } 601 | 602 | setUpBrowserInputHandlers() { 603 | const canvas = this.quadShaderCanvas.renderer.domElement 604 | canvas.tabIndex = 0 605 | canvas.focus() 606 | canvas.style.outline = "none" 607 | 608 | const downFunc = e => { 609 | this.state.keyStates[e.key] = true 610 | } 611 | const upFunc = e => { 612 | this.state.keyStates[e.key] = false 613 | } 614 | 615 | canvas.addEventListener("keydown", downFunc) 616 | canvas.addEventListener("keyup", upFunc) 617 | canvas.addEventListener("pointerdown", downFunc) 618 | canvas.addEventListener("pointerup", upFunc) 619 | 620 | // This is to get it working on mobile. 621 | // It's not actually playable though, at 622 | // least on my phone :/ 623 | canvas.addEventListener( 624 | "touchstart", 625 | e => { 626 | e.preventDefault() 627 | this.state.keyStates["ArrowUp"] = true 628 | }, 629 | false 630 | ) 631 | canvas.addEventListener( 632 | "touchend", 633 | e => { 634 | e.preventDefault() 635 | this.state.keyStates["ArrowUp"] = false 636 | }, 637 | false 638 | ) 639 | 640 | this.focused = true 641 | 642 | canvas.addEventListener("blur", e => { 643 | this.focused = false 644 | }) 645 | canvas.addEventListener("focus", e => { 646 | this.focused = true 647 | }) 648 | } 649 | 650 | updatePointDisplay() { 651 | document.getElementById("points").innerHTML = 652 | "" + Math.floor(this.state.points) 653 | } 654 | 655 | loadSounds() { 656 | // Probably not the best way of doing this, but it works... 657 | if ( 658 | window.location.hostname === "symbolflux.com" || 659 | window.location.hostname === "westoncb.com" 660 | ) { 661 | this.deathSound = new Howl({ 662 | src: ["https://s3.amazonaws.com/undergame-media/exit.mp3"], 663 | }) 664 | this.birthSound = new Howl({ 665 | src: ["https://s3.amazonaws.com/undergame-media/link.mp3"], 666 | }) 667 | this.caveShutSound = new Howl({ 668 | src: ["https://s3.amazonaws.com/undergame-media/thunder.mp3"], 669 | }) 670 | this.pointZoneSound = new Howl({ 671 | src: ["https://s3.amazonaws.com/undergame-media/ufo.mp3"], 672 | loop: true, 673 | }) 674 | this.caveOpenSound = new Howl({ 675 | src: [ 676 | "https://s3.amazonaws.com/undergame-media/powerdrain.mp3", 677 | ], 678 | }) 679 | } else { 680 | // Running locally 681 | 682 | this.deathSound = new Howl({ 683 | src: ["sounds/exit.mp3"], 684 | }) 685 | this.birthSound = new Howl({ 686 | src: ["sounds/link.mp3"], 687 | }) 688 | this.caveShutSound = new Howl({ 689 | src: ["sounds/thunder.mp3"], 690 | }) 691 | this.pointZoneSound = new Howl({ 692 | src: ["sounds/ufo.mp3"], 693 | loop: true, 694 | }) 695 | this.caveOpenSound = new Howl({ 696 | src: ["sounds/powerdrain.mp3"], 697 | }) 698 | } 699 | } 700 | 701 | createCaveDataTextures() { 702 | this.topHeightTex = this.getCaveDataTexture() 703 | this.bottomHeightTex = this.getCaveDataTexture() 704 | } 705 | 706 | // Called by QuadShaderCanvas 707 | resizeOccurred(canvasWidth, canvasHeight) { 708 | this.createCaveDataTextures() 709 | } 710 | 711 | // Called by Simulation 712 | tearDown() {} 713 | } 714 | 715 | module.exports = GameStateTransformer 716 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const THREE = require("three") 2 | const Events = require("./events.js") 3 | const Simulation = require("./simulation.js") 4 | const GameStateTransformer = require("./gameStateTransformer.js") 5 | import "./style.css" 6 | 7 | window.onload = () => { 8 | if (isMobile()) { 9 | const container = document.getElementById("canvas-container") 10 | container.style.cssText = ` 11 | position: absolute; 12 | left: 0; 13 | right: 0; 14 | top: 0; 15 | bottom: 0; 16 | width: 100%; 17 | height: 100%; 18 | ` 19 | // screen.orientation.lock("landscape") 20 | // const videoHTML = "
"; 21 | // container.innerHTML = videoHTML; 22 | 23 | // const textNode = document.createTextNode("Mobile isn't supported yet, so there's just this video."); 24 | // container.appendChild(textNode); 25 | } 26 | 27 | const simulation = new Simulation() 28 | const clock = new THREE.Clock() 29 | 30 | Events.enqueue("change_transformer", { 31 | transformer: new GameStateTransformer(), 32 | }) 33 | 34 | class MainLoop { 35 | static update(time) { 36 | requestAnimationFrame(MainLoop.update) 37 | 38 | simulation.update(time / 1000, clock.getDelta()) 39 | simulation.render() 40 | } 41 | } 42 | 43 | MainLoop.update(0) 44 | } 45 | 46 | const mobileRE = 47 | /(android|bb\d+|meego).+mobile|armv7l|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series[46]0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i 48 | 49 | const tabletRE = /android|ipad|playbook|silk/i 50 | 51 | function isMobile(opts) { 52 | if (!opts) opts = {} 53 | let ua = opts.ua 54 | if (!ua && typeof navigator !== "undefined") ua = navigator.userAgent 55 | if (ua && ua.headers && typeof ua.headers["user-agent"] === "string") { 56 | ua = ua.headers["user-agent"] 57 | } 58 | if (typeof ua !== "string") return false 59 | 60 | let result = mobileRE.test(ua) || (!!opts.tablet && tabletRE.test(ua)) 61 | 62 | if ( 63 | !result && 64 | opts.tablet && 65 | opts.featureDetect && 66 | navigator && 67 | navigator.maxTouchPoints > 1 && 68 | ua.indexOf("Macintosh") !== -1 && 69 | ua.indexOf("Safari") !== -1 70 | ) { 71 | result = true 72 | } 73 | 74 | return result 75 | } 76 | -------------------------------------------------------------------------------- /src/quadShaderCanvas.js: -------------------------------------------------------------------------------- 1 | const THREE = require("three") 2 | const Stats = require("stats-js") 3 | const AppState = require("./appState.js") 4 | 5 | /* 6 | This takes care of all the essentially boilerplate to draw on a full-canvas 7 | quad with a fragment shader. Does the three.js mesh/material and camera setup, 8 | places the three.js canvas into some designated element, handles resizing. 9 | */ 10 | 11 | class QuadShaderCanvas { 12 | /* 13 | containerElementId: id of a dom element which the three.js canvas should be fit to. 14 | fragmentShader: string containing the text of a fragment shader. 15 | */ 16 | constructor(containerElementId, fragmentShader, options = {}) { 17 | this.containerElementId = containerElementId 18 | const containerElement = document.getElementById( 19 | this.containerElementId 20 | ) 21 | 22 | this.width = containerElement.offsetWidth 23 | this.height = containerElement.offsetHeight 24 | 25 | this.resizeCallback = options.resizeHandler 26 | 27 | this.initThreeJS(options.showStats) 28 | this.initScene(fragmentShader) 29 | 30 | this.updateCanvasSize() 31 | } 32 | 33 | initThreeJS(showStats) { 34 | this.renderer = new THREE.WebGLRenderer({ antialias: false }) 35 | this.renderer.setPixelRatio(1) // this was using window.devicePixelRatio 36 | this.renderer.setSize(this.width, this.height) 37 | document 38 | .getElementById(this.containerElementId) 39 | .appendChild(this.renderer.domElement) 40 | 41 | if (showStats) { 42 | this.stats = new Stats() 43 | this.stats.setMode(0) 44 | this.stats.domElement.style.position = "absolute" 45 | this.stats.domElement.style.left = "0px" 46 | this.stats.domElement.style.top = "0px" 47 | document.body.appendChild(this.stats.domElement) 48 | } 49 | 50 | this.scene = new THREE.Scene() 51 | this.camera = new THREE.OrthographicCamera( 52 | this.width / -2, 53 | this.width / 2, 54 | this.height / 2, 55 | this.height / -2, 56 | 1, 57 | 2 58 | ) 59 | this.scene.add(this.camera) 60 | 61 | window.addEventListener( 62 | "resize", 63 | this.updateCanvasSize.bind(this), 64 | false 65 | ) 66 | } 67 | 68 | initScene(fragmentShader) { 69 | const uniforms = { 70 | time: { value: 1.0 }, 71 | resolution: { value: new THREE.Vector2(this.width, this.height) }, 72 | aspectRatio: { value: this.width / this.height }, 73 | } 74 | 75 | const geometry = new THREE.PlaneBufferGeometry(this.width, this.height) 76 | const material = new THREE.RawShaderMaterial({ 77 | uniforms, 78 | vertexShader: this.getVertexShader(), 79 | fragmentShader, 80 | }) 81 | this.mesh = new THREE.Mesh(geometry, material) 82 | this.scene.add(this.mesh) 83 | this.uniforms = material.uniforms 84 | } 85 | 86 | getVertexShader() { 87 | const vs = ` 88 | attribute vec3 position; 89 | 90 | void main(void) { 91 | gl_Position = vec4(position, 1.0); 92 | } 93 | ` 94 | 95 | return vs 96 | } 97 | 98 | render() { 99 | if (this.stats) this.stats.begin() 100 | 101 | this.renderer.render(this.scene, this.camera) 102 | 103 | if (this.stats) this.stats.end() 104 | } 105 | 106 | updateCanvasSize() { 107 | const containerElement = document.getElementById( 108 | this.containerElementId 109 | ) 110 | this.width = containerElement.offsetWidth 111 | this.height = containerElement.offsetHeight 112 | this.renderer.setSize(this.width, this.height) 113 | this.camera.updateProjectionMatrix() 114 | 115 | AppState.canvasWidth = this.width * 1 // these were using window.devicePixelRatio 116 | AppState.canvasHeight = this.height * 1 117 | 118 | this.uniforms.resolution.value.x = AppState.canvasWidth 119 | this.uniforms.resolution.value.y = AppState.canvasHeight 120 | 121 | this.uniforms.aspectRatio.value = this.width / this.height 122 | 123 | if (this.resizeCallback) { 124 | this.resizeCallback(AppState.canvasWidth, AppState.canvasHeight) 125 | } 126 | } 127 | } 128 | 129 | module.exports = QuadShaderCanvas 130 | -------------------------------------------------------------------------------- /src/simulation.js: -------------------------------------------------------------------------------- 1 | const StateTransformer = require('./StateTransformer.js'); 2 | const Events = require('./events.js'); 3 | 4 | /* 5 | This Simulation StateTransformer is always running and just 6 | manages other StateTransformers. It's responsible for swapping 7 | StateTransformers in response to 'change_transformer' events 8 | and calling the framework methods (e.g. update(...)/handleEvent(...)) 9 | on the current StateTransformer. 10 | */ 11 | 12 | class Simulation extends StateTransformer { 13 | constructor() { 14 | super(); 15 | 16 | this.subTransformer = new EmptyStateTransformer(); 17 | } 18 | 19 | update(time, deltaTime) { 20 | while(!Events.empty()) { 21 | this.handleEvent(Events.dequeue()); 22 | } 23 | 24 | this.subTransformer.update(time, deltaTime); 25 | } 26 | 27 | handleEvent(event) { 28 | if (event.name === 'change_transformer') { 29 | this.swapSubTransformer(event.data.transformer); 30 | } else { 31 | this.subTransformer.handleEvent(event); 32 | } 33 | } 34 | 35 | render() { 36 | this.subTransformer.render(); 37 | } 38 | 39 | swapSubTransformer(newTransformer) { 40 | this.subTransformer.tearDown(); 41 | 42 | newTransformer.setUp(); 43 | 44 | this.subTransformer = newTransformer; 45 | } 46 | 47 | setUp() {} 48 | tearDown() {} 49 | } 50 | 51 | class EmptyStateTransformer extends StateTransformer {} 52 | 53 | module.exports = Simulation; -------------------------------------------------------------------------------- /src/stateTransformer.js: -------------------------------------------------------------------------------- 1 | /* 2 | This just defines the interface for any StateTransformer. 3 | simulation.js is responsible for triggering the methods 4 | in the interface at the appropriate times, on the active 5 | StateTransformer. 6 | 7 | You can change the current StateTransformer using: 8 | 9 | Events.enqueue('change_transformer', {transformer: someStateTransformer}); 10 | */ 11 | class StateTransformer { 12 | setUp() {} 13 | tearDown() {} 14 | handleEvent(event) {} 15 | update(time, deltaTime) {} 16 | render() {} 17 | } 18 | 19 | module.exports = StateTransformer; -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: white; 3 | font-family: "Exo", sans-serif; 4 | } 5 | 6 | canvas { 7 | width: 100%; 8 | height: 100%; 9 | border: 3px solid black; 10 | } 11 | 12 | #canvas-container { 13 | width: 80%; 14 | height: 600px; 15 | position: absolute; 16 | margin: auto; 17 | top: 0; 18 | right: 0; 19 | bottom: 0; 20 | left: 0; 21 | padding: 0; 22 | text-align: center; 23 | } 24 | 25 | #points { 26 | position: absolute; 27 | left: 20px; 28 | top: 10px; 29 | color: white; 30 | font-size: 50px; 31 | opacity: 0.65; 32 | font-family: "Exo", sans-serif; 33 | } 34 | 35 | a { 36 | color: white; 37 | text-decoration: none; 38 | } 39 | 40 | a:hover { 41 | text-decoration: underline; 42 | } 43 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | const THREE = require("three") 2 | 3 | const METERS_TO_PIXELS = 50 * 1 // this was using window.devicePixelRatio 4 | 5 | // https://www.michaelbromley.co.uk/blog/simple-1d-noise-in-javascript/ 6 | var Simple1DNoise = function () { 7 | var MAX_VERTICES = 256 8 | var MAX_VERTICES_MASK = MAX_VERTICES - 1 9 | var amplitude = 1 10 | var scale = 1 11 | 12 | var r = [] 13 | 14 | for (var i = 0; i < MAX_VERTICES; ++i) { 15 | r.push(Math.random()) 16 | } 17 | 18 | var getVal = function (x) { 19 | var scaledX = x * scale 20 | var xFloor = Math.floor(scaledX) 21 | var t = scaledX - xFloor 22 | var tRemapSmoothstep = t * t * (3 - 2 * t) 23 | 24 | /// Modulo using & 25 | var xMin = xFloor & MAX_VERTICES_MASK 26 | var xMax = (xMin + 1) & MAX_VERTICES_MASK 27 | 28 | var y = lerp(r[xMin], r[xMax], tRemapSmoothstep) 29 | 30 | return y * amplitude 31 | } 32 | 33 | /** 34 | * Linear interpolation function. 35 | * @param a The lower integer value 36 | * @param b The upper integer value 37 | * @param t The value between the two 38 | * @returns {number} 39 | */ 40 | var lerp = function (a, b, t) { 41 | return a * (1 - t) + b * t 42 | } 43 | 44 | // return the API 45 | return { 46 | getVal: getVal, 47 | setAmplitude: function (newAmplitude) { 48 | amplitude = newAmplitude 49 | }, 50 | setScale: function (newScale) { 51 | scale = newScale 52 | }, 53 | } 54 | } 55 | 56 | let noiseGenerator1d = new Simple1DNoise() 57 | 58 | class Util { 59 | // See https://hansmuller-webkit.blogspot.com/2013/02/where-is-mouse.html 60 | static canvasMousePos(event, canvas) { 61 | const style = document.defaultView.getComputedStyle(canvas, null) 62 | 63 | function styleValue(property) { 64 | return parseInt(style.getPropertyValue(property), 10) || 0 // '10' is for base 10 65 | } 66 | 67 | const scaleX = canvas.width / styleValue("width") 68 | const scaleY = canvas.height / styleValue("height") 69 | 70 | const canvasRect = canvas.getBoundingClientRect() 71 | const canvasX = 72 | scaleX * 73 | (event.clientX - 74 | canvasRect.left - 75 | canvas.clientLeft - 76 | styleValue("padding-left")) 77 | const canvasY = 78 | scaleY * 79 | (event.clientY - 80 | canvasRect.top - 81 | canvas.clientTop - 82 | styleValue("padding-top")) 83 | 84 | // Need to look into pixel scaling issues more closely, but things work correctly 85 | // on my retina display and non-scaled monitor with this. 86 | return { 87 | x: canvasX / 1, // these were using window.devicePixelRatio 88 | y: canvasY / 1, 89 | } 90 | } 91 | 92 | static toPixels(meters) { 93 | return meters * METERS_TO_PIXELS 94 | } 95 | 96 | static toMeters(pixels) { 97 | return pixels / METERS_TO_PIXELS 98 | } 99 | 100 | static toPixelsV(vec) { 101 | return vec.clone().multiplyScalar(METERS_TO_PIXELS) 102 | } 103 | 104 | static toPixelsVModify(vec) { 105 | return vec.multiplyScalar(METERS_TO_PIXELS) 106 | } 107 | 108 | static toMetersV(vec) { 109 | return vec.clone().multiplyScalar(1 / METERS_TO_PIXELS) 110 | } 111 | 112 | static newNoiseSeed() { 113 | noiseGenerator1d = new Simple1DNoise() 114 | } 115 | 116 | static noise1d(x) { 117 | return noiseGenerator1d.getVal(x) 118 | } 119 | 120 | static step(min, max, value) { 121 | if (value <= min) return 0 122 | if (value >= max) return 1 123 | } 124 | 125 | static smoothstep(min, max, value) { 126 | const x = Math.max(0, Math.min(1, (value - min) / (max - min))) 127 | return x * x * (3 - 2 * x) 128 | } 129 | 130 | static objSpreadInto(obj, target) { 131 | Object.keys(obj).forEach(key => { 132 | target[key] = obj[key] 133 | }) 134 | } 135 | 136 | static getPropAtPath(obj, path) { 137 | const pathParts = path.split(".") 138 | let prop = obj 139 | 140 | for (let i = 0; i < pathParts.length; i++) { 141 | prop = prop[pathParts[i]] 142 | } 143 | 144 | return prop 145 | } 146 | 147 | static setPropAtPath(obj, path, value) { 148 | const pathParts = path.split(".") 149 | let prop = obj 150 | 151 | for (let i = 0; i < pathParts.length; i++) { 152 | if (i === pathParts.length - 1) { 153 | prop[pathParts[i]] = value 154 | } else { 155 | prop = prop[pathParts[i]] 156 | } 157 | } 158 | } 159 | 160 | static mix(x, y, a) { 161 | return x * (1 - a) + y * a 162 | } 163 | } 164 | 165 | module.exports = Util 166 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require("html-webpack-plugin") 2 | const path = require("path") 3 | const webpack = require("webpack") 4 | 5 | module.exports = { 6 | entry: { core: "./src/index.js" }, 7 | output: { 8 | filename: "main.bundle.js", 9 | path: path.resolve(__dirname, "dist"), 10 | library: { 11 | type: "umd", 12 | export: "default", 13 | name: "printeff", 14 | }, 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.js$/, 20 | exclude: /node_modules/, 21 | use: { 22 | loader: "babel-loader", 23 | options: { 24 | presets: ["@babel/preset-env"], 25 | }, 26 | }, 27 | }, 28 | { 29 | test: /\.css$/i, 30 | use: [ 31 | // Creates `style` nodes from JS strings 32 | "style-loader", 33 | // Translates CSS into CommonJS 34 | "css-loader", 35 | ], 36 | }, 37 | ], 38 | }, 39 | devServer: { 40 | liveReload: true, 41 | watchFiles: __dirname + "/src", 42 | open: false, 43 | hot: false, 44 | static: ["public", "dist"], 45 | devMiddleware: { 46 | writeToDisk: true, 47 | }, 48 | client: { 49 | overlay: true, 50 | }, 51 | }, 52 | } 53 | --------------------------------------------------------------------------------