Tower building game. Place blocks by clicking, tapping or spacebarring.
A clone of Stack https://itunes.apple.com/us/app/stack/id1080487957?mt=8
A Pen by Steve Gardner on CodePen.
| console.clear(); | |
| interface BlockReturn | |
| { | |
| placed?:any; | |
| chopped?:any; | |
| plane: 'x' | 'y' | 'z'; | |
| direction: number; | |
| bonus?: boolean; | |
| } | |
| class Stage | |
| { | |
| private container: any; | |
| private camera: any; | |
| private scene: any; | |
| private renderer: any; | |
| private light: any; | |
| private softLight: any; | |
| private group: any; | |
| constructor() | |
| { | |
| // container | |
| this.container = document.getElementById('game'); | |
| // renderer | |
| this.renderer = new THREE.WebGLRenderer({ | |
| antialias: true, | |
| alpha: false | |
| }); | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| this.renderer.setClearColor('#D0CBC7', 1); | |
| this.container.appendChild( this.renderer.domElement ); | |
| // scene | |
| this.scene = new THREE.Scene(); | |
| // camera | |
| let aspect = window.innerWidth / window.innerHeight; | |
| let d = 20; | |
| this.camera = new THREE.OrthographicCamera( - d * aspect, d * aspect, d, - d, -100, 1000); | |
| this.camera.position.x = 2; | |
| this.camera.position.y = 2; | |
| this.camera.position.z = 2; | |
| this.camera.lookAt(new THREE.Vector3(0, 0, 0)); | |
| //light | |
| this.light = new THREE.DirectionalLight(0xffffff, 0.5); | |
| this.light.position.set(0, 499, 0); | |
| this.scene.add(this.light); | |
| this.softLight = new THREE.AmbientLight( 0xffffff, 0.4 ); | |
| this.scene.add(this.softLight) | |
| window.addEventListener('resize', () => this.onResize()); | |
| this.onResize(); | |
| } | |
| setCamera(y:number, speed:number = 0.3) | |
| { | |
| TweenLite.to(this.camera.position, speed, {y: y + 4, ease: Power1.easeInOut}); | |
| TweenLite.to(this.camera.lookAt, speed, {y: y, ease: Power1.easeInOut}); | |
| } | |
| onResize() | |
| { | |
| let viewSize = 30; | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| this.camera.left = window.innerWidth / - viewSize; | |
| this.camera.right = window.innerWidth / viewSize; | |
| this.camera.top = window.innerHeight / viewSize; | |
| this.camera.bottom = window.innerHeight / - viewSize; | |
| this.camera.updateProjectionMatrix(); | |
| } | |
| render = function() | |
| { | |
| this.renderer.render(this.scene, this.camera); | |
| } | |
| add = function(elem) | |
| { | |
| this.scene.add(elem); | |
| } | |
| remove = function(elem) | |
| { | |
| this.scene.remove(elem); | |
| } | |
| } | |
| class Block | |
| { | |
| const STATES = {ACTIVE: 'active', STOPPED: 'stopped', MISSED: 'missed'}; | |
| const MOVE_AMOUNT = 12; | |
| dimension = { width: 0, height: 0, depth: 0} | |
| position = {x: 0, y: 0, z: 0}; | |
| mesh:any; | |
| state:string; | |
| index:number; | |
| speed:number; | |
| direction:number; | |
| colorOffset:number; | |
| color:number; | |
| material:any; | |
| workingPlane:string; | |
| workingDimension:string; | |
| targetBlock:Block; | |
| constructor(block:Block) | |
| { | |
| // set size and position | |
| this.targetBlock = block; | |
| this.index = (this.targetBlock ? this.targetBlock.index : 0) + 1; | |
| this.workingPlane = this.index % 2 ? 'x' : 'z'; | |
| this.workingDimension = this.index % 2 ? 'width' : 'depth'; | |
| // set the dimensions from the target block, or defaults. | |
| this.dimension.width = this.targetBlock ? this.targetBlock.dimension.width : 10; | |
| this.dimension.height = this.targetBlock ? this.targetBlock.dimension.height : 2; | |
| this.dimension.depth = this.targetBlock ? this.targetBlock.dimension.depth : 10; | |
| this.position.x = this.targetBlock ? this.targetBlock.position.x : 0; | |
| this.position.y = this.dimension.height * this.index; | |
| this.position.z = this.targetBlock ? this.targetBlock.position.z : 0; | |
| this.colorOffset = this.targetBlock ? this.targetBlock.colorOffset : Math.round(Math.random() * 100); | |
| // set color | |
| if(!this.targetBlock) | |
| { | |
| this.color = 0x333344; | |
| } | |
| else | |
| { | |
| let offset = this.index + this.colorOffset; | |
| var r = Math.sin(0.3 * offset) * 55 + 200; | |
| var g = Math.sin(0.3 * offset + 2) * 55 + 200; | |
| var b = Math.sin(0.3 * offset + 4) * 55 + 200; | |
| this.color = new THREE.Color( r / 255, g / 255, b / 255 ); | |
| } | |
| // state | |
| this.state = this.index > 1 ? this.STATES.ACTIVE : this.STATES.STOPPED; | |
| // set direction | |
| this.speed = -0.1 - (this.index * 0.005); | |
| if(this.speed < -4) this.speed = -4; | |
| this.direction = this.speed; | |
| // create block | |
| let geometry = new THREE.BoxGeometry( this.dimension.width, this.dimension.height, this.dimension.depth); | |
| geometry.applyMatrix( new THREE.Matrix4().makeTranslation(this.dimension.width/2, this.dimension.height/2, this.dimension.depth/2) ); | |
| this.material = new THREE.MeshToonMaterial( {color: this.color, shading: THREE.FlatShading} ); | |
| this.mesh = new THREE.Mesh( geometry, this.material ); | |
| this.mesh.position.set(this.position.x, this.position.y + (this.state == this.STATES.ACTIVE ? 0 : 0), this.position.z); | |
| if(this.state == this.STATES.ACTIVE) | |
| { | |
| this.position[this.workingPlane] = Math.random() > 0.5 ? -this.MOVE_AMOUNT : this.MOVE_AMOUNT; | |
| } | |
| } | |
| reverseDirection() | |
| { | |
| this.direction = this.direction > 0 ? this.speed : Math.abs(this.speed); | |
| } | |
| place():BlockReturn | |
| { | |
| this.state = this.STATES.STOPPED; | |
| let overlap = this.targetBlock.dimension[this.workingDimension] - Math.abs(this.position[this.workingPlane] - this.targetBlock.position[this.workingPlane]); | |
| let blocksToReturn:BlockReturn = { | |
| plane: this.workingPlane, | |
| direction: this.direction | |
| }; | |
| if(this.dimension[this.workingDimension] - overlap < 0.3) | |
| { | |
| overlap = this.dimension[this.workingDimension]; | |
| blocksToReturn.bonus = true; | |
| this.position.x = this.targetBlock.position.x; | |
| this.position.z = this.targetBlock.position.z; | |
| this.dimension.width = this.targetBlock.dimension.width; | |
| this.dimension.depth = this.targetBlock.dimension.depth; | |
| } | |
| if(overlap > 0) | |
| { | |
| let choppedDimensions = { width: this.dimension.width, height: this.dimension.height, depth: this.dimension.depth }; | |
| choppedDimensions[this.workingDimension] -= overlap; | |
| this.dimension[this.workingDimension] = overlap; | |
| let placedGeometry = new THREE.BoxGeometry( this.dimension.width, this.dimension.height, this.dimension.depth); | |
| placedGeometry.applyMatrix( new THREE.Matrix4().makeTranslation(this.dimension.width/2, this.dimension.height/2, this.dimension.depth/2) ); | |
| let placedMesh = new THREE.Mesh( placedGeometry, this.material ); | |
| let choppedGeometry = new THREE.BoxGeometry( choppedDimensions.width, choppedDimensions.height, choppedDimensions.depth); | |
| choppedGeometry.applyMatrix( new THREE.Matrix4().makeTranslation(choppedDimensions.width/2, choppedDimensions.height/2, choppedDimensions.depth/2) ); | |
| let choppedMesh = new THREE.Mesh( choppedGeometry, this.material ); | |
| let choppedPosition = { | |
| x: this.position.x, | |
| y: this.position.y, | |
| z: this.position.z | |
| } | |
| if(this.position[this.workingPlane] < this.targetBlock.position[this.workingPlane]) | |
| { | |
| this.position[this.workingPlane] = this.targetBlock.position[this.workingPlane] | |
| } | |
| else | |
| { | |
| choppedPosition[this.workingPlane] += overlap; | |
| } | |
| placedMesh.position.set(this.position.x, this.position.y, this.position.z); | |
| choppedMesh.position.set(choppedPosition.x, choppedPosition.y, choppedPosition.z); | |
| blocksToReturn.placed = placedMesh; | |
| if(!blocksToReturn.bonus) blocksToReturn.chopped = choppedMesh; | |
| } | |
| else | |
| { | |
| this.state = this.STATES.MISSED; | |
| } | |
| this.dimension[this.workingDimension] = overlap; | |
| return blocksToReturn; | |
| } | |
| tick() | |
| { | |
| if(this.state == this.STATES.ACTIVE) | |
| { | |
| let value = this.position[this.workingPlane]; | |
| if(value > this.MOVE_AMOUNT || value < -this.MOVE_AMOUNT) this.reverseDirection(); | |
| this.position[this.workingPlane] += this.direction; | |
| this.mesh.position[this.workingPlane] = this.position[this.workingPlane]; | |
| } | |
| } | |
| } | |
| class Game | |
| { | |
| const STATES = { | |
| 'LOADING': 'loading', | |
| 'PLAYING': 'playing', | |
| 'READY': 'ready', | |
| 'ENDED': 'ended', | |
| 'RESETTING': 'resetting' | |
| } | |
| blocks:Block[] = []; | |
| state:string = this.STATES.LOADING; | |
| // groups | |
| newBlocks:any; | |
| placedBlocks:any; | |
| choppedBlocks:any; | |
| // UI elements | |
| scoreContainer:any; | |
| mainContainer:any; | |
| startButton:any; | |
| instructions:any; | |
| constructor() | |
| { | |
| this.stage = new Stage(); | |
| this.mainContainer = document.getElementById('container'); | |
| this.scoreContainer = document.getElementById('score'); | |
| this.startButton = document.getElementById('start-button'); | |
| this.instructions = document.getElementById('instructions'); | |
| this.scoreContainer.innerHTML = '0'; | |
| this.newBlocks = new THREE.Group(); | |
| this.placedBlocks = new THREE.Group(); | |
| this.choppedBlocks = new THREE.Group(); | |
| this.stage.add(this.newBlocks); | |
| this.stage.add(this.placedBlocks); | |
| this.stage.add(this.choppedBlocks); | |
| this.addBlock(); | |
| this.tick(); | |
| this.updateState(this.STATES.READY); | |
| document.addEventListener('keydown', e => | |
| { | |
| if(e.keyCode == 32) this.onAction() | |
| }); | |
| document.addEventListener('click', e => | |
| { | |
| this.onAction(); | |
| }); | |
| document.addEventListener('touchstart', e => | |
| { | |
| e.preventDefault(); | |
| // this.onAction(); | |
| // ☝️ this triggers after click on android so you | |
| // insta-lose, will figure it out later. | |
| }); | |
| } | |
| updateState(newState) | |
| { | |
| for(let key in this.STATES) this.mainContainer.classList.remove(this.STATES[key]); | |
| this.mainContainer.classList.add(newState); | |
| this.state = newState; | |
| } | |
| onAction() | |
| { | |
| switch(this.state) | |
| { | |
| case this.STATES.READY: | |
| this.startGame(); | |
| break; | |
| case this.STATES.PLAYING: | |
| this.placeBlock(); | |
| break; | |
| case this.STATES.ENDED: | |
| this.restartGame(); | |
| break; | |
| } | |
| } | |
| startGame() | |
| { | |
| if(this.state != this.STATES.PLAYING) | |
| { | |
| this.scoreContainer.innerHTML = '0'; | |
| this.updateState(this.STATES.PLAYING); | |
| this.addBlock(); | |
| } | |
| } | |
| restartGame() | |
| { | |
| this.updateState(this.STATES.RESETTING); | |
| let oldBlocks = this.placedBlocks.children; | |
| let removeSpeed = 0.2; | |
| let delayAmount = 0.02; | |
| for(let i = 0; i < oldBlocks.length; i++) | |
| { | |
| TweenLite.to(oldBlocks[i].scale, removeSpeed, {x: 0, y: 0, z: 0, delay: (oldBlocks.length - i) * delayAmount, ease: Power1.easeIn, onComplete: () => this.placedBlocks.remove(oldBlocks[i])}) | |
| TweenLite.to(oldBlocks[i].rotation, removeSpeed, {y: 0.5, delay: (oldBlocks.length - i) * delayAmount, ease: Power1.easeIn}) | |
| } | |
| let cameraMoveSpeed = removeSpeed * 2 + (oldBlocks.length * delayAmount); | |
| this.stage.setCamera(2, cameraMoveSpeed); | |
| let countdown = {value: this.blocks.length - 1}; | |
| TweenLite.to(countdown, cameraMoveSpeed, {value: 0, onUpdate: () => {this.scoreContainer.innerHTML = String(Math.round(countdown.value))}}) | |
| this.blocks = this.blocks.slice(0, 1); | |
| setTimeout(() => { | |
| this.startGame(); | |
| }, cameraMoveSpeed * 1000) | |
| } | |
| placeBlock() | |
| { | |
| let currentBlock = this.blocks[this.blocks.length - 1]; | |
| let newBlocks:BlockReturn = currentBlock.place(); | |
| this.newBlocks.remove(currentBlock.mesh); | |
| if(newBlocks.placed) this.placedBlocks.add(newBlocks.placed); | |
| if(newBlocks.chopped) | |
| { | |
| this.choppedBlocks.add(newBlocks.chopped); | |
| let positionParams = {y: '-=30', ease: Power1.easeIn, onComplete: () => this.choppedBlocks.remove(newBlocks.chopped)} | |
| let rotateRandomness = 10; | |
| let rotationParams = { | |
| delay: 0.05, | |
| x: newBlocks.plane == 'z' ? ((Math.random() * rotateRandomness) - (rotateRandomness/2)) : 0.1, | |
| z: newBlocks.plane == 'x' ? ((Math.random() * rotateRandomness) - (rotateRandomness/2)) : 0.1, | |
| y: Math.random() * 0.1, | |
| }; | |
| if(newBlocks.chopped.position[newBlocks.plane] > newBlocks.placed.position[newBlocks.plane]) | |
| { | |
| positionParams[newBlocks.plane] = '+=' + (40 * Math.abs(newBlocks.direction)); | |
| } | |
| else | |
| { | |
| positionParams[newBlocks.plane] = '-=' + (40 * Math.abs(newBlocks.direction)); | |
| } | |
| TweenLite.to(newBlocks.chopped.position, 1, positionParams); | |
| TweenLite.to(newBlocks.chopped.rotation, 1, rotationParams); | |
| } | |
| this.addBlock(); | |
| } | |
| addBlock() | |
| { | |
| let lastBlock = this.blocks[this.blocks.length - 1]; | |
| if(lastBlock && lastBlock.state == lastBlock.STATES.MISSED) | |
| { | |
| return this.endGame(); | |
| } | |
| this.scoreContainer.innerHTML = String(this.blocks.length - 1); | |
| let newKidOnTheBlock = new Block(lastBlock); | |
| this.newBlocks.add(newKidOnTheBlock.mesh); | |
| this.blocks.push(newKidOnTheBlock); | |
| this.stage.setCamera(this.blocks.length * 2); | |
| if(this.blocks.length >= 5) this.instructions.classList.add('hide'); | |
| } | |
| endGame() | |
| { | |
| this.updateState(this.STATES.ENDED); | |
| } | |
| tick() | |
| { | |
| this.blocks[this.blocks.length - 1].tick(); | |
| this.stage.render(); | |
| requestAnimationFrame(() => {this.tick()}); | |
| } | |
| } | |
| let game = new Game(); |
| <script src="https://codepen.io/steveg3003/pen/zBVakw.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r83/three.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/latest/TweenMax.min.js"></script> |
| @import url('https://fonts.googleapis.com/css?family=Comfortaa'); | |
| $color-dark: #333344; | |
| html, body | |
| { | |
| margin: 0; | |
| overflow: hidden; | |
| height: 100%; | |
| width: 100%; | |
| position: relative; | |
| font-family: 'Comfortaa', cursive; | |
| } | |
| #container | |
| { | |
| width: 100%; | |
| height: 100%; | |
| #score | |
| { | |
| position: absolute; | |
| top: 20px; | |
| width: 100%; | |
| text-align: center; | |
| font-size: 10vh; | |
| transition: transform 0.5s ease; | |
| color: $color-dark; | |
| transform: translatey(-200px) scale(1); | |
| } | |
| #game | |
| { | |
| position: absolute; | |
| top: 0; | |
| right: 0; | |
| bottom: 0; | |
| left: 0; | |
| } | |
| .game-over | |
| { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 85%; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| * | |
| { | |
| transition: opacity 0.5s ease, transform 0.5s ease; | |
| opacity: 0; | |
| transform: translatey(-50px); | |
| color: $color-dark; | |
| } | |
| h2 | |
| { | |
| margin: 0; | |
| padding: 0; | |
| font-size: 40px; | |
| } | |
| } | |
| .game-ready | |
| { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: space-around; | |
| #start-button | |
| { | |
| transition: opacity 0.5s ease, transform 0.5s ease; | |
| opacity: 0; | |
| transform: translatey(-50px); | |
| border: 3px solid $color-dark; | |
| padding: 10px 20px; | |
| background-color: transparent; | |
| color: $color-dark; | |
| font-size: 30px; | |
| } | |
| } | |
| #instructions | |
| { | |
| position: absolute; | |
| width: 100%; | |
| top: 16vh; | |
| left: 0; | |
| text-align: center; | |
| transition: opacity 0.5s ease, transform 0.5s ease; | |
| opacity: 0; | |
| &.hide | |
| { | |
| opacity: 0 !important; | |
| } | |
| } | |
| &.playing, &.resetting | |
| { | |
| #score | |
| { | |
| transform: translatey(0px) scale(1); | |
| } | |
| } | |
| &.playing | |
| { | |
| #instructions | |
| { | |
| opacity: 1; | |
| } | |
| } | |
| &.ready | |
| { | |
| .game-ready | |
| { | |
| #start-button | |
| { | |
| opacity: 1; | |
| transform: translatey(0); | |
| } | |
| } | |
| } | |
| &.ended | |
| { | |
| #score | |
| { | |
| transform: translatey(6vh) scale(1.5); | |
| } | |
| .game-over | |
| { | |
| * | |
| { | |
| opacity: 1; | |
| transform: translatey(0); | |
| } | |
| p | |
| { | |
| transition-delay: 0.3s; | |
| } | |
| } | |
| } | |
| } | |
| <meta name="viewport" content="width=device-width,user-scalable=no"> | |
| <div id="container"> | |
| <div id="game"></div> | |
| <div id="score">0</div> | |
| <div id="instructions">Click (or press the spacebar) to place the block</div> | |
| <div class="game-over"> | |
| <h2>Game Over</h2> | |
| <p>You did great, you're the best.</p> | |
| <p>Click or spacebar to start again</p> | |
| </div> | |
| <div class="game-ready"> | |
| <div id="start-button">Start</div> | |
| <div></div> | |
| </div> | |
| </div> |
Tower building game. Place blocks by clicking, tapping or spacebarring.
A clone of Stack https://itunes.apple.com/us/app/stack/id1080487957?mt=8
A Pen by Steve Gardner on CodePen.