From b6b98c997ec69d9a71d78c08412175baf832c711 Mon Sep 17 00:00:00 2001 From: Nishant Kaushal <101548649+nishant0708@users.noreply.github.com> Date: Thu, 4 Jul 2024 23:50:36 +0530 Subject: [PATCH] Added Space Dominators --- Games/Space_Dominators/app.js | 224 +++++ Games/Space_Dominators/appCharacters.js | 818 ++++++++++++++++++ Games/Space_Dominators/appEffects.js | 450 ++++++++++ Games/Space_Dominators/appLevel.js | 532 ++++++++++++ Games/Space_Dominators/appObjects.js | 580 +++++++++++++ Games/Space_Dominators/engine/build/build.bat | 98 +++ .../Space_Dominators/engine/build/build.html | 1 + .../engine/build/engineBuild.js | 25 + .../Space_Dominators/engine/build/index.html | 1 + .../engine/build/setupBuild.bat | 7 + Games/Space_Dominators/engine/engine.js | 228 +++++ Games/Space_Dominators/engine/engineAudio.js | 273 ++++++ Games/Space_Dominators/engine/engineDebug.js | 453 ++++++++++ Games/Space_Dominators/engine/engineDraw.js | 131 +++ Games/Space_Dominators/engine/engineInput.js | 152 ++++ Games/Space_Dominators/engine/engineObject.js | 268 ++++++ .../Space_Dominators/engine/engineParticle.js | 200 +++++ .../engine/engineTileLayer.js | 255 ++++++ Games/Space_Dominators/engine/engineUtil.js | 137 +++ Games/Space_Dominators/engine/engineWebGL.js | 326 +++++++ Games/Space_Dominators/favicon.ico | Bin 0 -> 766 bytes Games/Space_Dominators/index.html | 25 + Games/Space_Dominators/package.json | 12 + Games/Space_Dominators/screenshot.png | Bin 0 -> 73137 bytes Games/Space_Dominators/tiles.png | Bin 0 -> 7857 bytes 25 files changed, 5196 insertions(+) create mode 100644 Games/Space_Dominators/app.js create mode 100644 Games/Space_Dominators/appCharacters.js create mode 100644 Games/Space_Dominators/appEffects.js create mode 100644 Games/Space_Dominators/appLevel.js create mode 100644 Games/Space_Dominators/appObjects.js create mode 100644 Games/Space_Dominators/engine/build/build.bat create mode 100644 Games/Space_Dominators/engine/build/build.html create mode 100644 Games/Space_Dominators/engine/build/engineBuild.js create mode 100644 Games/Space_Dominators/engine/build/index.html create mode 100644 Games/Space_Dominators/engine/build/setupBuild.bat create mode 100644 Games/Space_Dominators/engine/engine.js create mode 100644 Games/Space_Dominators/engine/engineAudio.js create mode 100644 Games/Space_Dominators/engine/engineDebug.js create mode 100644 Games/Space_Dominators/engine/engineDraw.js create mode 100644 Games/Space_Dominators/engine/engineInput.js create mode 100644 Games/Space_Dominators/engine/engineObject.js create mode 100644 Games/Space_Dominators/engine/engineParticle.js create mode 100644 Games/Space_Dominators/engine/engineTileLayer.js create mode 100644 Games/Space_Dominators/engine/engineUtil.js create mode 100644 Games/Space_Dominators/engine/engineWebGL.js create mode 100644 Games/Space_Dominators/favicon.ico create mode 100644 Games/Space_Dominators/index.html create mode 100644 Games/Space_Dominators/package.json create mode 100644 Games/Space_Dominators/screenshot.png create mode 100644 Games/Space_Dominators/tiles.png diff --git a/Games/Space_Dominators/app.js b/Games/Space_Dominators/app.js new file mode 100644 index 0000000000..24beabb2fd --- /dev/null +++ b/Games/Space_Dominators/app.js @@ -0,0 +1,224 @@ +/* + Javascript Space Game + By Nishant kaushal + +*/ + +'use strict'; + +const clampCamera = !debug; +const lowGraphicsSettings = glOverlay = !window['chrome']; // only chromium uses high settings +const startCameraScale = 4*16; +const defaultCameraScale = 4*16; +const maxPlayers = 4; + +const team_none = 0; +const team_player = 1; +const team_enemy = 2; + +let updateWindowSize, renderWindowSize, gameplayWindowSize; + +engineInit( + +/////////////////////////////////////////////////////////////////////////////// +()=> // appInit +{ + resetGame(); + cameraScale = startCameraScale; +}, + +/////////////////////////////////////////////////////////////////////////////// +()=> // appUpdate +{ + const cameraSize = vec2(mainCanvas.width, mainCanvas.height).scale(1/cameraScale); + renderWindowSize = cameraSize.add(vec2(5)); + + gameplayWindowSize = vec2(mainCanvas.width, mainCanvas.height).scale(1/defaultCameraScale); + updateWindowSize = gameplayWindowSize.add(vec2(30)); + //debugRect(cameraPos, maxGameplayCameraSize); + //debugRect(cameraPos, updateWindowSize); + + if (debug) + { + randSeeded(randSeeded(randSeeded(randSeed = Date.now()))); // set random seed for debug mode stuf + if (keyWasPressed(81)) + new Enemy(mousePosWorld); + + if (keyWasPressed(84)) + { + //for(let i=30;i--;) + new Prop(mousePosWorld); + } + + if (keyWasPressed(69)) + explosion(mousePosWorld); + + if (keyIsDown(89)) + { + let e = new ParticleEmitter(mousePosWorld); + + // test + e.collideTiles = 1; + //e.tileIndex=7; + e.emitSize = 2; + e.colorStartA = new Color(1,1,1,1); + e.colorStartB = new Color(0,1,1,1); + e.colorEndA = new Color(0,0,1,0); + e.colorEndB = new Color(0,.5,1,0); + e.emitConeAngle = .1; + e.particleTime = 1 + e.speed = .3 + e.elasticity = .1 + e.gravityScale = 1; + //e.additive = 1; + e.angle = -PI; + } + + if (mouseWheel) // mouse zoom + cameraScale = clamp(cameraScale*(1-mouseWheel/10), defaultTileSize.x*16, defaultTileSize.x/16); + + //if (keyWasPressed(77)) + // playSong([[[,0,219,,,,,1.1,,-.1,-50,-.05,-.01,1],[2,0,84,,,.1,,.7,,,,.5,,6.7,1,.05]],[[[0,-1,1,0,5,0],[1,1,8,8,0,3]]],[0,0,0,0],90]) // music test + + if (keyWasPressed(77)) + players[0].pos = mousePosWorld; + + /*if (keyWasPressed(32)) + { + skyParticles && skyParticles.destroy(); + tileLayer.destroy(); + tileBackgroundLayer.destroy(); + tileParallaxLayers.forEach((tileParallaxLayer)=>tileParallaxLayer.destroy()); + randomizeLevelParams(); + applyArtToLevel(); + }*/ + if (keyWasPressed(78)) + nextLevel(); + } + + // restart if no lives left + let minDeadTime = 1e3; + for(const player of players) + minDeadTime = min(minDeadTime, player && player.isDead() ? player.deadTimer.get() : 0); + + if (minDeadTime > 3 && (keyWasPressed(90) || keyWasPressed(32) || gamepadWasPressed(0)) || keyWasPressed(82)) + resetGame(); + + if (levelEndTimer.get() > 3) + nextLevel(); +}, + +/////////////////////////////////////////////////////////////////////////////// +()=> // appUpdatePost +{ + if (players.length == 1) + { + const player = players[0]; + if (!player.isDead()) + cameraPos = cameraPos.lerp(player.pos, clamp(player.getAliveTime()/2)); + } + else + { + // camera follows average pos of living players + let posTotal = vec2(); + let playerCount = 0; + let cameraOffset = 1; + for(const player of players) + { + if (player && !player.isDead()) + { + ++playerCount; + posTotal = posTotal.add(player.pos.add(vec2(0,cameraOffset))); + } + } + + if (playerCount) + cameraPos = cameraPos.lerp(posTotal.scale(1/playerCount), .2); + } + + // spawn players if they don't exist + for(let i = maxPlayers;i--;) + { + if (!players[i] && (gamepadWasPressed(0, i)||gamepadWasPressed(1, i))) + { + ++playerLives; + new Player(checkpointPos, i); + } + } + + // clamp to bottom and sides of level + if (clampCamera) + { + const w = mainCanvas.width/2/cameraScale+1; + const h = mainCanvas.height/2/cameraScale+2; + cameraPos.y = max(cameraPos.y, h); + if (w*2 < tileCollisionSize.x) + cameraPos.x = clamp(cameraPos.x, tileCollisionSize.x - w, w); + } + + updateParallaxLayers(); + + updateSky(); +}, + +/////////////////////////////////////////////////////////////////////////////// +()=> // appRender +{ + const gradient = mainContext.createLinearGradient(0,0,0,mainCanvas.height); + gradient.addColorStop(0,levelSkyColor.rgba()); + gradient.addColorStop(1,levelSkyHorizonColor.rgba()); + mainContext.fillStyle = gradient; + //mainContext.fillStyle = levelSkyColor.rgba(); + mainContext.fillRect(0,0,mainCanvas.width, mainCanvas.height); + + drawStars(); +}, + +/////////////////////////////////////////////////////////////////////////////// +()=> // appRenderPost +{ + //let minAliveTime = 9; + //for(const player of players) + // minAliveTime = min(minAliveTime, player.getAliveTime()); + + //const livesPercent = percent(minAliveTime, 5, 4) + //const s = 8; + //const offset = 100*livesPercent; + //mainContext.drawImage(tileImage, 32, 8, s, s, 32, mainCanvas.height-90, s*9, s*9); + mainContext.textAlign = 'center'; + const p = percent(gameTimer.get(), 8, 10); + + //mainContext.globalCompositeOperation = 'difference'; + mainContext.fillStyle = new Color(0,0,0,p).rgba(); + if (p > 0) + { + //mainContext.fillStyle = (new Color).setHSLA(time/3,1,.5,p).rgba(); + mainContext.font = '1.5in impact'; + mainContext.fillText(' space dominator', mainCanvas.width/2, 140); + } + + mainContext.font = '.5in impact'; + p > 0 && mainContext.fillText('A Game by Nishant kaushal',mainCanvas.width/2, 210); + + // check if any enemies left + let enemiesCount = 0; + for (const o of engineCollideObjects) + { + if (o.isCharacter && o.team == team_enemy) + { + ++enemiesCount; + const pos = vec2(mainCanvas.width/2 + (o.pos.x - cameraPos.x)*30,mainCanvas.height-20); + drawRectScreenSpace(pos, o.size.scale(20), o.color.scale(1,.6)); + } + } + + if (!enemiesCount && !levelEndTimer.isSet()) + levelEndTimer.set(); + + mainContext.fillStyle = new Color(0,0,0).rgba(); + mainContext.fillText('Level ' + level + ' Lives ' + playerLives + ' Enemies ' + enemiesCount, mainCanvas.width/2, mainCanvas.height-40); + + // fade in level transition + const fade = levelEndTimer.isSet() ? percent(levelEndTimer.get(), 3, 1) : percent(levelTimer.get(), .5, 2); + drawRect(cameraPos, vec2(1e3), new Color(0,0,0,fade)) +}); \ No newline at end of file diff --git a/Games/Space_Dominators/appCharacters.js b/Games/Space_Dominators/appCharacters.js new file mode 100644 index 0000000000..01fa3919f8 --- /dev/null +++ b/Games/Space_Dominators/appCharacters.js @@ -0,0 +1,818 @@ +/* + Javascript Space Game + By Nishant kaushal + +*/ + +'use strict'; + +const aiEnable = 1; +const debugAI = 0; +const maxCharacterSpeed = .2; + +class Character extends GameObject +{ + constructor(pos, sizeScale = 1) + { + super(pos, vec2(.6,.95).scale(sizeScale), 32); + + this.health = this.healthMax = this.canBurn = this.isCharacter = 1; + this.sizeScale = sizeScale; + this.groundTimer = new Timer; + this.jumpTimer = new Timer; + this.pressedJumpTimer = new Timer; + this.preventJumpTimer = new Timer; + this.dodgeTimer = new Timer; + this.dodgeRechargeTimer = new Timer; + this.deadTimer = new Timer; + this.blinkTimer = new Timer; + this.moveInput = vec2(); + this.extraAdditiveColor = new Color(0,0,0,0); + this.color = new Color; + this.eyeColor = new Color; + this.bodyTile = 3; + this.headTile = 2; + this.renderOrder = 10; + this.overkill = this.grenadeCount = this.walkCyclePercent = 0; + this.grendeThrowTimer = new Timer; + this.setCollision(); + } + + update() + { + this.lastPos = this.pos.copy(); + this.gravityScale = 1; // reset default gravity (incase climbing ladder) + + if (this.isDead() || !this.inUpdateWindow() && !this.persistent) + { + super.update(); + return; // ignore offscreen objects + } + + let moveInput = this.moveInput.copy(); + + // allow grabbing ladder at head or feet + let touchingLadder = 0; + for(let y=2;y--;) + { + const testPos = this.pos.add(vec2(0, y + .1*this.moveInput.y - this.size.y*.5)); + const collisionData = getTileCollisionData(testPos); + touchingLadder |= collisionData == tileType_ladder; + } + if (!touchingLadder) + this.climbingLadder = 0; + else if (this.moveInput.y) + this.climbingLadder = 1; + + if (this.dodgeTimer.active()) + { + // update roll + this.angle = this.getMirrorSign(2*PI*this.dodgeTimer.getPercent()); + + if (this.groundObject) + this.velocity.x += this.getMirrorSign(.1); + + // apply damage to enemies when rolling + forEachObject(this.pos, this.size, (o)=> + { + if (o.isCharacter && o.team != this.team && !o.isDead()) + o.damage(1, this); + }); + } + else + this.angle = 0; + + if (this.climbingLadder) + { + this.gravityScale = this.climbingWall = this.groundObject = 0; + this.jumpTimer.unset(); + this.groundTimer.unset(); + this.velocity = this.velocity.multiply(vec2(.85)).add(vec2(0,.02*moveInput.y)); + + const delta = (this.pos.x|0)+.5 - this.pos.x; + this.velocity.x += .02*delta*abs(moveInput.x ? 0:moveInput.y); + moveInput.x *= .2; + + // exit ladder if ground is below + this.climbingLadder = moveInput.y >= 0 || getTileCollisionData(this.pos.subtract(vec2(0,1))) <= 0; + } + else + { + // update jumping and ground check + if (this.groundObject || this.climbingWall) + this.groundTimer.set(.1); + + if (this.groundTimer.active() && !this.dodgeTimer.active()) + { + // is on ground + if (this.pressedJumpTimer.active() + && !this.jumpTimer.active() + && !this.preventJumpTimer.active()) + { + // start jump + if (this.climbingWall) + { + this.velocity.y = .25; + } + else + { + this.velocity.y = .15; + this.jumpTimer.set(.2); + } + this.preventJumpTimer.set(.5); + playSound(sound_jump, this.pos); + } + } + + if (this.jumpTimer.active() && !this.climbingWall) + { + // update variable height jump + this.groundTimer.unset(); + if (this.holdingJump && this.velocity.y > 0 && this.jumpTimer.active()) + this.velocity.y += .017; + } + + if (!this.groundObject) + { + // air control + if (sign(moveInput.x) == sign(this.velocity.x)) + moveInput.x *= .1; // moving with velocity + else + moveInput.x *= .2; // moving against velocity (stopping) + + // slight extra gravity when moving down + if (this.velocity.y < 0) + this.velocity.y += gravity*.2; + } + } + + if (this.pressedDodge && !this.dodgeTimer.active() && !this.dodgeRechargeTimer.active()) + { + // start dodge + this.dodgeTimer.set(.4); + this.dodgeRechargeTimer.set(2); + this.jumpTimer.unset(); + this.extinguish(); + playSound(sound_dodge, this.pos); + + if (!this.groundObject && this.getAliveTime() > .2) + this.velocity.y += .2; + } + + // apply movement acceleration and clamp + this.velocity.x = clamp(this.velocity.x + moveInput.x * .042, maxCharacterSpeed, -maxCharacterSpeed); + + // call parent, update physics + const oldVelocity = this.velocity.copy(); + super.update(); + if (!this.isPlayer && !this.dodgeTimer.active()) + { + // apply collision damage + const deltaSpeedSquared = this.velocity.subtract(oldVelocity).lengthSquared(); + deltaSpeedSquared > .1 && this.damage(10*deltaSpeedSquared); + } + + if (this.climbingLadder || this.groundTimer.active() && !this.dodgeTimer.active()) + { + const speed = this.velocity.length(); + this.walkCyclePercent += speed * .5; + this.walkCyclePercent = speed > .01 ? mod(this.walkCyclePercent, 1) : 0; + } + else + this.walkCyclePercent = 0; + + this.weapon.triggerIsDown = this.holdingShoot && !this.dodgeTimer.active(); + if (!this.dodgeTimer.active()) + { + if (this.grenadeCount > 0 && this.pressingThrow && !this.wasPressingThrow && !this.grendeThrowTimer.active()) + { + // throw greande + --this.grenadeCount; + const grenade = new Grenade(this.pos); + grenade.velocity = this.velocity.add(vec2(this.getMirrorSign(),rand(.8,.7)).normalize(.25+rand(.02))); + grenade.angleVelocity = this.getMirrorSign() * rand(.8,.5); + playSound(sound_jump, this.pos); + this.grendeThrowTimer.set(1); + } + this.wasPressingThrow = this.pressingThrow; + } + + // update mirror + if (this.moveInput.x && !this.dodgeTimer.active()) + this.mirror = this.moveInput.x < 0; + + // clamp x pos + this.pos.x = clamp(this.pos.x, levelSize.x-2, 2); + + // randomly blink + rand() < .005 && this.blinkTimer.set(rand(.2,.1)); + } + + render() + { + if (!isOverlapping(this.pos, this.size, cameraPos, renderWindowSize)) + return; + + // set tile to use + this.tileIndex = this.isDead() ? this.bodyTile : this.climbingLadder || this.groundTimer.active() ? this.bodyTile + 2*this.walkCyclePercent|0 : this.bodyTile+1; + + let additive = this.additiveColor.add(this.extraAdditiveColor); + if (this.isPlayer && !this.isDead() && this.dodgeRechargeTimer.elapsed() && this.dodgeRechargeTimer.get() < .2) + { + const v = .6 - this.dodgeRechargeTimer.get()*3; + additive = additive.add(new Color(0,v,v,0)).clamp(); + } + + const sizeScale = this.sizeScale; + const color = this.color.scale(this.burnColorPercent(),1); + const eyeColor = this.eyeColor.scale(this.burnColorPercent(),1); + + const bodyPos = this.pos.add(vec2(0,-.1+.06*Math.sin(this.walkCyclePercent*PI)).scale(sizeScale)); + drawTile(bodyPos, vec2(sizeScale), this.tileIndex, this.tileSize, color, this.angle, this.mirror, additive); + drawTile(this.pos.add(vec2(this.getMirrorSign(.05),.46).scale(sizeScale).rotate(-this.angle)),vec2(sizeScale/2),this.headTile,vec2(8), color,this.angle,this.mirror, additive); + + //for(let i = this.grenadeCount; i--;) + // drawTile(bodyPos, vec2(.5), 5, vec2(8), new Color, this.angle, this.mirror, additive); + + const blinkScale = this.canBlink ? this.isDead() ? .3: .5 + .5*Math.cos(this.blinkTimer.getPercent()*PI*2) : 1; + drawTile(this.pos.add(vec2(this.getMirrorSign(.05),.46).scale(sizeScale).rotate(-this.angle)),vec2(sizeScale/2, blinkScale*sizeScale/2),this.headTile+1,vec2(8), eyeColor, this.angle, this.mirror, this.additiveColor); + } + + damage(damage, damagingObject) + { + if (this.destroyed) + return; + + if (this.team == team_player) + { + // safety window after spawn + if (godMode || this.getAliveTime() < 2) + return; + } + + if (this.isDead() && !this.persistent) + { + this.overkill += damage; + if (this.overkill > 5) + { + makeBlood(this.pos, 300); + this.destroy(); + } + } + + this.blinkTimer.set(rand(.5,.4)); + makeBlood(damagingObject ? damagingObject.pos : this.pos); + super.damage(damage, damagingObject); + } + + kill(damagingObject) + { + if (this.isDead()) + return 0; + + if (levelWarmup) + { + this.destroy(); + return 1; + } + + this.deadTimer.set(); + this.size = this.size.scale(.5); + + makeBlood(this.pos, 300); + playSound(sound_die, this.pos); + + this.team = team_none; + this.health = 0; + const fallDirection = damagingObject ? sign(damagingObject.velocity.x) : randSign(); + this.angleVelocity = fallDirection*rand(.22,.14); + this.angleDamping = .9; + this.weapon && this.weapon.destroy(); + + // move to back layer + this.renderOrder = 1; + } + + collideWithTile(data, pos) + { + if (!data) + return; + + if (data == tileType_ladder) + { + if (pos.y + 1 > this.lastPos.y - this.size.y*.5) + return; + + if (getTileCollisionData(pos.add(vec2(0,1))) // above + && !(getTileCollisionData(pos.add(vec2(1,0))) // left + && getTileCollisionData(pos.add(vec2(1,0)))) // right + ) + return; // dont collide if something above it and nothing to left or right + + // allow standing on top of ladders + return !this.climbingLadder; + } + + // break blocks above + const d = pos.y - this.pos.y; + if (!this.climbingLadder && this.velocity.y > .1 && d > 0 && d < this.size.y*.5) + { + if (destroyTile(pos)) + { + this.velocity.y = 0; + return; + } + } + + return 1; + } + + collideWithObject(o) + { + if (this.isDead()) + return super.collideWithObject(o); + + if (o.velocity.lengthSquared() > .04) + { + const v = o.velocity.subtract(this.velocity); + const m = 25*o.mass * v.lengthSquared(); + if (!o.groundObject && o.isCrushing && !this.persistent && o.velocity.y < 0 && this.pos.y < o.pos.y - o.size.y/2 && abs(o.pos.x - this.pos.x) < o.size.x*.5) + { + // crushing + this.damage(1e3, o); + if (this.isDead()) + { + makeBlood(this.pos, 300); + this.destroy(); + } + } + else if (m > 1) + this.damage(4*m|0, o) + } + + return super.collideWithObject(o); + } +} + +/////////////////////////////////////////////////////////////////////////////// + +const type_weak = 0; +const type_normal = 1; +const type_strong = 2; +const type_elite = 3; +const type_grenade= 4; +const type_count = 5; + +function alertEnemies(pos, playerPos) +{ + const radius = 4; + forEachObject(pos, radius, (o)=>{o.team == team_enemy && o.alert && o.alert(playerPos)}); + debugAI && debugCircle(pos, radius, '#0ff6'); +} + +class Enemy extends Character +{ + constructor(pos) + { + super(pos); + + this.team = team_enemy; + this.sawPlayerTimer = new Timer; + this.reactionTimer = new Timer; + this.facePlayerTimer = new Timer; + this.holdJumpTimer = new Timer; + this.shootTimer = new Timer; + this.maxVisionRange = 12; + + this.type = randSeeded()**3*min(level+1,type_count)|0; + + let health = 1 + this.type; + this.eyeColor = new Color(1,.5,0); + if (this.type == type_weak) + { + this.color = new Color(0,1,0); + this.size = this.size.scale(this.sizeScale = .9); + } + else if (this.type == type_normal) + { + this.color = new Color(0,.4,1); + } + else if (this.type == type_strong) + { + this.color = new Color(1,0,0); + this.eyeColor = new Color(1,1,0); + } + else if (this.type == type_elite) + { + this.color = new Color(1,1,1); + this.eyeColor = new Color(1,0,0); + this.maxVisionRange = 15; + } + else if (this.type == type_grenade) + { + this.color = new Color(.7,0,1); + this.eyeColor = new Color(0,0,0); + this.grenadeCount = 3; + this.canBurn = 0; + } + + if (this.isBig = randSeeded() < .05) + { + // chance of large enemy with extra health + this.size = this.size.scale(this.sizeScale = 1.3); + health *= 2; + this.grenadeCount *= 10; + this.maxVisionRange = 15; + --levelEnemyCount; + } + + this.health = this.healthMax = health; + this.color = this.color.mutate(); + this.mirror = rand() < .5; + + new Weapon(this.pos, this); + --levelEnemyCount; + + this.sightCheckFrame = rand(9)|0; + } + + update() + { + if (!aiEnable || levelWarmup || this.isDead() || !this.inUpdateWindow()) + { + if (this.weapon) + this.weapon.triggerIsDown = 0; + super.update(); + return; // ignore offscreen objects + } + + if (this.weapon) + this.weapon.localPos = this.weapon.localOffset.scale(this.sizeScale); + + // update check if players are visible + const sightCheckFrames = 9; + ASSERT(this.sawPlayerPos || !this.sawPlayerTimer.isSet()); + if (frame%sightCheckFrames == this.sightCheckFrame) + { + const sawRecently = this.sawPlayerTimer.isSet() && this.sawPlayerTimer.get() < 5; + const visionRangeSquared = (sawRecently ? this.maxVisionRange * 1.2 : this.maxVisionRange)**2; + debugAI && debugCircle(this.pos, visionRangeSquared**.5, '#f003', .1); + for(const player of players) + { + // check range + if (player && !player.isDead()) + if (sawRecently || this.getMirrorSign() == sign(player.pos.x - this.pos.x)) + if (sawRecently || abs(player.pos.x - this.pos.x) > abs(player.pos.y - this.pos.y) ) // 45 degree slope + if (this.pos.distanceSquared(player.pos) < visionRangeSquared) + { + const raycastHit = tileCollisionRaycast(this.pos, player.pos); + if (!raycastHit) + { + this.alert(player.pos, 1); + debugAI && debugLine(this.pos, player.pos, '#0f0',.1) + break; + } + debugAI && debugLine(this.pos, player.pos, '#f00',.1) + debugAI && raycastHit && debugPoint(raycastHit, '#ff0',.1) + } + } + + if (sawRecently) + { + // alert nearby enemies + alertEnemies(this.pos, this.sawPlayerPos); + } + } + + this.pressedDodge = this.climbingWall = this.pressingThrow = 0; + + if (this.burnTimer.isSet()) + { + // burning, run around + this.facePlayerTimer.unset(); + + // random jump + if (rand()< .005) + { + this.pressedJumpTimer.set(.05); + this.holdJumpTimer.set(rand(.05)); + } + + // random movement + if (rand()<.05) + this.moveInput.x = randSign()*rand(.6, .3); + this.moveInput.y = 0; + + // random dodge + if (this.type == type_elite) + this.pressedDodge = 1; + else if (this.groundObject) + this.pressedDodge = rand() < .005; + } + else if (this.sawPlayerTimer.isSet() && this.sawPlayerTimer.get() < 10) + { + debugAI && debugPoint(this.sawPlayerPos, '#f00'); + + // wall climb + if (this.type >= type_strong && this.moveInput.x && !this.velocity.x && this.velocity.y < 0) + { + this.velocity.y *=.8; + this.climbingWall = 1; + this.pressedJumpTimer.set(.1); + this.holdJumpTimer.set(rand(.2)); + } + + const timeSinceSawPlayer = this.sawPlayerTimer.get(); + this.weapon.localAngle *= .8; + if (this.reactionTimer.active()) + { + // just saw player for first time, act surprised + this.moveInput.x = 0; + } + else if (timeSinceSawPlayer < 5) + { + debugAI && debugRect(this.pos, this.size, '#f00'); + + if (!this.dodgeTimer.active()) + { + const playerDirection = sign(this.sawPlayerPos.x - this.pos.x); + if (this.type == type_grenade && rand() < .002 && this.getMirrorSign() == playerDirection) + this.pressingThrow = 1; + + // actively fighting player + if (rand()<.05) + this.facePlayerTimer.set(rand(2,.5)); + + // random jump + if (rand()<(this.type < type_strong ? .0005 : .005)) + { + this.pressedJumpTimer.set(.1); + this.holdJumpTimer.set(rand(.2)); + } + + // random movement + if (rand()<(this.isBig?.05:.02)) + this.moveInput.x = 0; + else if (rand()<.01) + this.moveInput.x = rand()<.6 ? playerDirection*rand(.5, .2) : -playerDirection*rand(.4, .2); + if (rand()<.03) + this.moveInput.y = rand()<.5 ? 0 : randSign()*rand(.4, .2); + + // random shoot + if (abs(this.sawPlayerPos.y - this.pos.y) < 4) + if (!this.shootTimer.isSet() || this.shootTimer.get() > 1) + rand() < (this.type > type_weak ? .02 : .01) && this.shootTimer.set(this.isBig ? rand(2,1) : .05); + } + + // random dodge + if (this.type == type_elite) + this.pressedDodge = rand() < .01 && timeSinceSawPlayer < .5; + } + else + { + // was fighting but lost player + debugAI && debugRect(this.pos, this.size, '#ff0'); + + if (rand()<.04) + this.facePlayerTimer.set(rand(2,.5)); + + // random movement + if (rand()<.02) + this.moveInput.x = 0; + else if (rand()<.01) + this.moveInput.x = randSign()*rand(.4, .2); + + // random jump + if (rand() < (this.sawPlayerPos.y > this.pos.y ? .002 : .001)) + { + this.pressedJumpTimer.set(.1); + this.holdJumpTimer.set(rand(.2)); + } + + // random shoot + if (!this.shootTimer.isSet() || this.shootTimer.get() > 5) + rand() < .001 && this.shootTimer.set(rand(.2,.1)); + + // move up/down in dirction last player was seen + this.moveInput.y = clamp(this.sawPlayerPos.y - this.pos.y,.5,-.5); + } + } + else + { + // try to act normal + if (rand()<.03) + this.moveInput.x = 0; + else if (rand()<.005) + this.moveInput.x = randSign()*rand(.2, .1); + else if (rand()<.001) + this.moveInput.x = randSign()*1e-9; // hack: look in a direction + + this.weapon.localAngle = lerp(.1, .7, this.weapon.localAngle); + this.reactionTimer.unset(); + } + + if (this.isBig && this.type != type_elite) + { + // big enemies cant jump + this.pressedJumpTimer.unset(); + this.holdJumpTimer.unset(); + } + this.holdingShoot = this.shootTimer.active(); + this.holdingJump = this.holdJumpTimer.active(); + + super.update(); + + // override default mirror + if (this.facePlayerTimer.active() && !this.dodgeTimer.active() && !this.reactionTimer.active()) + this.mirror = this.sawPlayerPos.x < this.pos.x; + } + + alert(playerPos, resetSawPlayer) + { + if (resetSawPlayer || !this.sawPlayerTimer.isSet()) + { + if (!this.reactionTimer.isSet()) + { + this.reactionTimer.set(rand(1,.5)*(this.type == type_weak ? 2 : 1)); + this.facePlayerTimer.set(rand(2,1)); + if (this.groundObject && rand() < .2) + this.velocity.y += .1; // random jump + } + + this.sawPlayerTimer.set(); + this.sawPlayerPos = playerPos; + } + } + + damage(damage, damagingObject) + { + super.damage(damage, damagingObject); + if (!this.isDead()) + { + this.alert(damagingObject ? damagingObject.pos.subtract(damagingObject.velocity.normalize()) : this.pos, 1); + this.reactionTimer.set(rand(1,.5)); + this.shootTimer.unset(); + } + } + + kill(damagingObject) + { + if (this.isDead()) + return 0; + + super.kill(damagingObject); + levelWarmup || ++totalKills; + } +} + +/////////////////////////////////////////////////////////////////////////////// + +class Player extends Character +{ + constructor(pos, playerIndex=0) + { + super(pos); + + this.grenadeCount = 3; + this.burnTime = 2; + + this.eyeColor = (new Color).setHSLA(-playerIndex*.6,1,.5); + if (playerIndex) + { + this.color = (new Color).setHSLA(playerIndex*.3-.3,.5,.5); + this.extraAdditiveColor = (new Color).setHSLA(playerIndex*.3-.3,1,.1,0); + } + + this.bodyTile = 5; + this.headTile = 18; + this.playerIndex = playerIndex; + this.renderOrder = 20 + 10*playerIndex; + this.walkSoundTime = 0; + this.persistent = this.wasHoldingJump = this.canBlink = this.isPlayer = 1; + this.team = team_player; + + new Weapon(this.pos, this); + players[playerIndex] = this; + + // small jump on spawn + this.velocity.y = .2; + this.mirror = playerIndex%2; + --playerLives; + } + + update() + { + if (this.isDead()) + { + if (this.persistent && playerLives) + { + if (players.length == 1) + { + if (this.deadTimer.get() > 2) + { + this.persistent = 0; + new Player(checkpointPos, this.playerIndex); + playSound(sound_jump, cameraPos); + } + } + else + { + // respawn only if all players dead, or checkpoint touched + let hasLivingPlayers = 0; + let minDeadTime = 1e3; + for(const player of players) + { + if (player) + { + minDeadTime = min(minDeadTime, player.isDead() ? player.deadTimer.get() : 1e3); + hasLivingPlayers |= (!player.isDead() && player.getAliveTime() > .1); + } + } + + if (minDeadTime > 2) + { + if (!hasLivingPlayers) + { + // respawn all + this.persistent = 0; + new Player(checkpointPos.add(vec2(1-this.playerIndex/2,0)), this.playerIndex); + this.playerIndex || playSound(sound_jump, cameraPos); + } + else if (checkpointTimer.active()) + { + // respawn if checkpoint active + this.persistent = 0; + const player = new Player(checkpointPos, this.playerIndex); + playSound(sound_jump, cameraPos); + } + } + } + } + + super.update(); + return; + } + + // wall climb + this.climbingWall = 0; + if (this.moveInput.x && !this.velocity.x && this.velocity.y < 0) + { + this.velocity.y *=.8; + this.climbingWall = 1; + } + + // movement control + this.moveInput.x = isUsingGamepad || this.playerIndex ? gamepadStick(0, this.playerIndex).x : keyIsDown(39) - keyIsDown(37); + + this.moveInput.y = isUsingGamepad || this.playerIndex ? gamepadStick(0, this.playerIndex).y : keyIsDown(38) - keyIsDown(40); + + // jump + this.holdingJump = (!this.playerIndex && keyIsDown(38)) || gamepadIsDown(0, this.playerIndex); + if (!this.holdingJump) + this.pressedJumpTimer.unset(); + else if (!this.wasHoldingJump || this.climbingWall) + this.pressedJumpTimer.set(.3); + this.wasHoldingJump = this.holdingJump; + + // controls + this.holdingShoot = !this.playerIndex && (mouseIsDown(0) || keyIsDown(90)) || gamepadIsDown(2, this.playerIndex); + this.pressingThrow = !this.playerIndex && (mouseIsDown(2) || keyIsDown(67)) || gamepadIsDown(1, this.playerIndex); + this.pressedDodge = !this.playerIndex && (mouseIsDown(1) || keyIsDown(88)) || gamepadIsDown(3, this.playerIndex); + + super.update(); + + // update walk sound + this.walkSoundTime += abs(this.velocity.x); + if (abs(this.velocity.x) > .01 && this.groundTimer.active() && !this.dodgeTimer.active()) + { + if (this.walkSoundTime > 1) + { + this.walkSoundTime = 0; + playSound(sound_walk, this.pos); + } + } + else + this.walkSoundTime = .5; + + if (players.length > 1 && !this.isDead()) + { + // move to other player if offscreen and multiplayer + if (!isOverlapping(this.pos, this.size, cameraPos, gameplayWindowSize)) + { + // move to location of another player if not falling off a cliff + if (tileCollisionRaycast(this.pos,vec2(this.pos.x,0))) + { + for(const player of players) + if (player && player != this && !player.isDead()) + { + this.pos = player.pos.copy(); + this.velocity = vec2(); + playSound(sound_jump, this.pos); + } + } + else + this.kill(); + } + } + } +} \ No newline at end of file diff --git a/Games/Space_Dominators/appEffects.js b/Games/Space_Dominators/appEffects.js new file mode 100644 index 0000000000..7fae8a7b56 --- /dev/null +++ b/Games/Space_Dominators/appEffects.js @@ -0,0 +1,450 @@ +/* + Javascript Space Game + By Nishant kaushal + +*/ + +'use strict'; + +const precipitationEnable = 1; +const debugFire = 0; + +/////////////////////////////////////////////////////////////////////////////// +// sounds + +const sound_shoot = [,,90,,.01,.03,4,,,,,,,9,50,.2,,.2,.01]; +const sound_destroyTile = [.5,,1e3,.02,,.2,1,3,.1,,,,,1,-30,.5,,.5]; +const sound_die = [.5,.4,126,.05,,.2,1,2.09,,-4,,,1,1,1,.4,.03]; +const sound_jump = [.4,.2,250,.04,,.04,,,1,,,,,3]; +const sound_dodge = [.4,.2,150,.05,,.05,,,-1,,,,,4,,,,,.02]; +const sound_walk = [.3,.1,70,,,.01,4,,,,-9,.1,,,,,,.5]; +const sound_explosion = [2,.2,72,.01,.01,.2,4,,,,,,,1,,.5,.1,.5,.02]; +const sound_checkpoint = [.6,0,500,,.04,.3,1,2,,,570,.02,.02,,,,.04]; +const sound_rain = [.02,,1e3,2,,2,,,,,,,,99]; +const sound_wind = [.01,.3,2e3,2,1,2,,,,,,,1,2,,,,,,.1]; +const sound_grenade = [.5,.01,300,,,.02,3,.22,,,-9,.2,,,,,,.5]; + +/////////////////////////////////////////////////////////////////////////////// +// special effects + +const persistentParticleDestroyCallback = (particle)=> +{ + // copy particle to tile layer on death + ASSERT(particle.tileIndex < 0); // quick draw to tile layer uses canvas 2d so must be untextured + if (particle.groundObject) + tileLayer.drawTile(particle.pos, particle.size, particle.tileIndex, particle.tileSize, particle.color, particle.angle, particle.mirror); +} + +function makeBlood(pos, amount=50) +{ + const emitter = new ParticleEmitter( + pos, 1, .1, amount, PI, // pos, emitSize, emitTime, emitRate, emiteCone + undefined, undefined, // tileIndex, tileSize + new Color(1,0,0), new Color(.5,0,0), // colorStartA, colorStartB + new Color(1,0,0), new Color(.5,0,0), // colorEndA, colorEndB + 3, .1, .1, .1, .1, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed + 1, .95, .7, PI, 0, // damping, angleDamping, gravityScale, particleCone, fadeRate, + .5, 1 // randomness, collide, additive, randomColorLinear, renderOrder + ); + emitter.particleDestroyCallback = persistentParticleDestroyCallback; + return emitter; +} + +function makeFire(pos = vec2()) +{ + return new ParticleEmitter( + pos, 1, 0, 60, PI, // pos, emitSize, emitTime, emitRate, emiteCone + 0, undefined, // tileIndex, tileSize + new Color(1,1,0), new Color(1,.5,.5), // colorStartA, colorStartB + new Color(1,0,0), new Color(1,.5,.1), // colorEndA, colorEndB + .5, .5, .1, .01, .1, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed + .95, .1, -.05, PI, .5, // damping, angleDamping, gravityScale, particleCone, fadeRate, + .5, 0, 1); // randomness, collide, additive, randomColorLinear, renderOrder +} + +function makeDebris(pos, color = new Color, amount = 100) +{ + const color2 = color.lerp(new Color, .5); + const emitter = new ParticleEmitter( + pos, 1, .1, amount, PI, // pos, emitSize, emitTime, emitRate, emiteCone + undefined, undefined, // tileIndex, tileSize + color, color2, // colorStartA, colorStartB + color, color2, // colorEndA, colorEndB + 3, .2, .2, .1, .05, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed + 1, .95, .4, PI, 0, // damping, angleDamping, gravityScale, particleCone, fadeRate, + .5, 1 // randomness, collide, additive, randomColorLinear, renderOrder + ); + emitter.elasticity = .3; + emitter.particleDestroyCallback = persistentParticleDestroyCallback; + return emitter; +} + +function makeWater(pos, amount=400) +{ + // overall spray + new ParticleEmitter( + pos, 1, .05, 400, PI, // pos, emitSize, emitTime, emitRate, emiteCone + 0, undefined, // tileIndex, tileSize + new Color(1,1,1,.5), new Color(.5,1,1,.2), // colorStartA, colorStartB + new Color(1,1,1,.5), new Color(.5,1,1,.2), // colorEndA, colorEndB + .5, .5, 2, .1, .05, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed + .9, 1, 0, PI, .5, // damping, angleDamping, gravityScale, particleCone, fadeRate, + .5, 0, 0, 0, 1e9 // randomness, collide, additive, randomColorLinear, renderOrder + ); + + // droplets + const emitter = new ParticleEmitter( + pos, 1, .1, amount, PI, // pos, emitSize, emitTime, emitRate, emiteCone + 0, undefined, // tileIndex, tileSize + new Color(.8,1,1,.6), new Color(.5,.5,1,.2), // colorStartA, colorStartB + new Color(.8,1,1,.6), new Color(.5,.5,1,.2), // colorEndA, colorEndB + 2, .1, .1, .2, 0, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed + .99, 1, .5, PI, .2, // damping, angleDamping, gravityScale, particleCone, fadeRate, + .5, 1 // randomness, collide, additive, randomColorLinear, renderOrder + ); + emitter.elasticity = .2; + emitter.trailScale = 2; + + // put out fires + const radius = 3; + forEachObject(pos, 3, (o)=> + { + if (o.isGameObject) + { + o.burnTimer.isSet() && o.extinguish(); + const d = o.pos.distance(pos); + const p = percent(d, radius/2, radius); + const force = o.pos.subtract(pos).normalize(p*radius*.2); + o.applyForce(force); + if (o.isDead && o.isDead()) + o.angleVelocity += randSign()*rand(radius/4,.3); + } + }); + + debugFire && debugCircle(pos, radius, '#0ff', 1) + + return emitter; +} + +/////////////////////////////////////////////////////////////////////////////// + +function explosion(pos, radius=2) +{ + ASSERT(radius > 0); + if (levelWarmup) + return; + + const damage = radius*2; + + // destroy level + for(let x = -radius; x < radius; ++x) + { + const h = (radius**2 - x**2)**.5; + for(let y = -h; y <= h; ++y) + destroyTile(pos.add(vec2(x,y)), 0, 0); + } + + // cleanup neighbors + const cleanupRadius = radius + 1; + for(let x = -cleanupRadius; x < cleanupRadius; ++x) + { + const h = (cleanupRadius**2 - x**2)**.5; + for(let y = -h; y < h; ++y) + decorateTile(pos.add(vec2(x,y)).int()); + } + + // kill/push objects + const maxRangeSquared = (radius*1.5)**2; + forEachObject(pos, radius*3, (o)=> + { + const d = o.pos.distance(pos); + if (o.isGameObject) + { + // do damage + d < radius && o.damage(damage); + + // catch fire + d < radius*1.5 && o.burn(); + } + + // push + const p = percent(d, radius, 2*radius); + const force = o.pos.subtract(pos).normalize(p*radius*.2); + o.applyForce(force); + if (o.isDead && o.isDead()) + o.angleVelocity += randSign()*rand(p*radius/4,.3); + }); + + playSound(sound_explosion, pos); + debugFire && debugCircle(pos, maxRangeSquared**.5, '#f00', 2); + debugFire && debugCircle(pos, radius**.5, '#ff0', 2); + + // smoke + new ParticleEmitter( + pos, radius/2, .2, 50*radius, PI, // pos, emitSize, emitTime, emitRate, emiteCone + 0, undefined, // tileIndex, tileSize + new Color(0,0,0), new Color(0,0,0), // colorStartA, colorStartB + new Color(0,0,0,0), new Color(0,0,0,0), // colorEndA, colorEndB + 1, .5, 2, .1, .05, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed + .9, 1, -.3, PI, .1, // damping, angleDamping, gravityScale, particleCone, fadeRate, + .5, 0, 0, 0, 1e8 // randomness, collide, additive, randomColorLinear, renderOrder + ); + + // fire + new ParticleEmitter( + pos, radius/2, .1, 100*radius, PI, // pos, emitSize, emitTime, emitRate, emiteCone + 0, undefined, // tileIndex, tileSize + new Color(1,.5,.1), new Color(1,.1,.1), // colorStartA, colorStartB + new Color(1,.5,.1,0), new Color(1,.1,.1,0), // colorEndA, colorEndB + .5, .5, 2, .1, .05, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed + .9, 1, 0, PI, .05, // damping, angleDamping, gravityScale, particleCone, fadeRate, + .5, 0, 1, 0, 1e9 // randomness, collide, additive, randomColorLinear, renderOrder + ); +} + +/////////////////////////////////////////////////////////////////////////////// + +class TileCascadeDestroy extends EngineObject +{ + constructor(pos, cascadeChance=1, glass=0) + { + super(pos, vec2()); + this.cascadeChance = cascadeChance; + this.destroyTimer = new Timer(glass ? .05 : rand(.3, .1)); + } + + update() + { + if (this.destroyTimer.elapsed()) + { + destroyTile(this.pos, 1, 1, this.cascadeChance); + this.destroy(); + } + } +} + +function decorateBackgroundTile(pos) +{ + const tileData = getTileBackgroundData(pos); + if (tileData <= 0) + return; // no need to clear if background cant change + + // round corners + for(let i=4;i--;) + { + // check corner neighbors + const neighborTileDataA = getTileBackgroundData(pos.add(vec2().setAngle(i*PI/2))); + const neighborTileDataB = getTileBackgroundData(pos.add(vec2().setAngle((i+1)%4*PI/2))); + + if (neighborTileDataA > 0 | neighborTileDataB > 0) + continue; + + const directionVector = vec2().setAngle(i*PI/2+PI/4, 10).int(); + let drawPos = pos.add(vec2(.5)) // center + .scale(16).add(directionVector).int(); // direction offset + + // clear rect without any scaling to prevent blur from filtering + const s = 2; + tileBackgroundLayer.context.clearRect( + drawPos.x - s/2|0, + tileBackgroundLayer.canvas.height - drawPos.y - s/2|0, + s|0, s|0); + } +} + +function decorateTile(pos) +{ + ASSERT((pos.x|0) == pos.x && (pos.y|0)== pos.y); + const tileData = getTileCollisionData(pos); + if (tileData <= 0) + { + tileData || tileLayer.setData(pos, new TileLayerData, 1); // force it to clear if it is empty + return; + } + + if (tileData != tileType_dirt & + tileData != tileType_base & + tileData != tileType_pipeV & + tileData != tileType_pipeH & + tileData != tileType_solid) + return; + + for(let i=4;i--;) + { + // outline towards neighbors of differing type + const neighborTileData = getTileCollisionData(pos.add(vec2().setAngle(i*PI/2))); + if (neighborTileData == tileData) + continue; + + // hacky code to make pixel perfect outlines + let size = tileData == tileType_dirt ? vec2( rand(16,8), 2) : vec2( 16, 1); + i&1 && (size = size.flip()); + + const color = tileData == tileType_dirt ? levelGroundColor.mutate(.1) : new Color(.1,.1,.1); + tileLayer.context.fillStyle = color.rgba(); + const drawPos = pos.scale(16); + if (tileData == tileType_dirt) + tileLayer.context.fillRect( + drawPos.x + ((i==1?14:0)+(i&1?0:8-size.x/2)) |0, + tileLayer.canvas.height - drawPos.y + ((i==0?-14:0)-(i&1?8-size.y/2:0)) |0, + size.x|0, -size.y|0); + else + tileLayer.context.fillRect( + drawPos.x + (i==1?15:0) |0, + tileLayer.canvas.height - drawPos.y + (i==0?-15:0) |0, + size.x|0, -size.y|0); + } +} + +function destroyTile(pos, makeSound = 1, cleanNeighbors = 1, maxCascadeChance = 1) +{ + // pos must be an int + pos = pos.int(); + + // destroy tile + const tileType = getTileCollisionData(pos); + + if (!tileType) return 1; // empty + if (tileType == tileType_solid) return 0; // indestructable + + const centerPos = pos.add(vec2(.5)); + const layerData = tileLayer.getData(pos); + if (layerData) + { + makeDebris(centerPos, layerData.color.mutate()); + makeSound && playSound(sound_destroyTile, centerPos); + + setTileCollisionData(pos, tileType_empty); + tileLayer.setData(pos, new TileLayerData, 1); // set and clear tile + + // cleanup neighbors + if (cleanNeighbors) + { + for(let i=-1;i<=1;++i) + for(let j=-1;j<=1;++j) + decorateTile(pos.add(vec2(i,j))); + } + + // if weak earth, random chance of delayed destruction of tile directly above + if (tileType == tileType_glass) + { + maxCascadeChance = 1; + if (getTileCollisionData(pos.add(vec2(0,-1))) == tileType) + new TileCascadeDestroy(pos.add(vec2(0,-1)), 1, 1); + } + else if (tileType != tileType_dirt) + maxCascadeChance = 0; + + if (rand() < maxCascadeChance && getTileCollisionData(pos.add(vec2(0,1))) == tileType) + new TileCascadeDestroy(pos.add(vec2(0,1)), maxCascadeChance * .4, tileType == tileType_glass); + } + + return 1; +} + +/////////////////////////////////////////////////////////////////////////////// + +function drawStars() +{ + randSeed = levelSeed; + for(let i = lowGraphicsSettings ? 400 : 1e3; i--;) + { + let size = randSeeded(6, 1); + let speed = randSeeded() < .9 ? randSeeded(5) : randSeeded(99,9); + let color = (new Color).setHSLA(randSeeded(.2,-.3), randSeeded()**9, randSeeded(1,.5), randSeeded(.9,.3)); + if (i < 9) + { + // suns or moons + size = randSeeded()**3*99 + 9; + speed = randSeeded(5); + color = (new Color).setHSLA(randSeeded(), randSeeded(), randSeeded(1,.5)).add(levelSkyColor.scale(.5)).clamp(); + } + + const w = mainCanvas.width+400, h = mainCanvas.height+400; + const screenPos = vec2( + (randSeeded(w)+time*speed)%w-200, + (randSeeded(h)+time*speed*randSeeded(1,.2))%h-200); + + if (lowGraphicsSettings) + { + // drawing stars with gl wont work in low graphics mode, just draw rects + mainContext.fillStyle = color.rgba(); + if (size < 9) + mainContext.fillRect(screenPos.x, screenPos.y, size, size); + else + mainContext.beginPath(mainContext.fill(mainContext.arc(screenPos.x, screenPos.y, size, 0, 9))); + } + else + drawTileScreenSpace(screenPos, vec2(size), 0, vec2(16), color); + } +} + +function updateSky() +{ + if (!skyParticles) + return; + + let skyParticlesPos = cameraPos.add(vec2(rand(-40,40),0)); + const raycastHit = tileCollisionRaycast(vec2(skyParticlesPos.x, levelSize.y), vec2(skyParticlesPos.x, 0)); + if (raycastHit && raycastHit.y > cameraPos.y+10) + skyParticlesPos = raycastHit; + skyParticles.pos = skyParticlesPos.add(vec2(0,20)); + + if (rand() < .002) + { + skyParticles.emitRate = clamp(skyParticles.emitRate + rand(200,-200), 500); + skyParticles.angle = clamp(skyParticles.angle + rand(.3,-.3),PI+.5,PI-.5); + } + + if (!levelWarmup && !skySoundTimer.active()) + { + skySoundTimer.set(rand(2,1)); + playSound(skyRain ? sound_rain : sound_wind, skyParticlesPos, 20, skyParticles.emitRate/1e3); + if (rand() < .1) + playSound(sound_wind, skyParticlesPos, 20, rand(skyParticles.emitRate/1e3)); + } +} + +/////////////////////////////////////////////////////////////////////////////// + +let tileParallaxLayers = []; + +function generateParallaxLayers() +{ + tileParallaxLayers = []; + for(let i=0; i<3; ++i) + { + const parallaxSize = vec2(600,300), startGroundLevel = rand(99,120)+i*30; + const tileParallaxLayer = tileParallaxLayers[i] = new TileLayer(vec2(), parallaxSize); + let groundLevel = startGroundLevel, groundSlope = rand(1,-1); + tileParallaxLayer.renderOrder = -3e3+i; + tileParallaxLayer.canvas.width = parallaxSize.x; + + const layerColor = levelColor.mutate(.2).lerp(levelSkyColor,.95-i*.15); + const gradient = tileParallaxLayer.context.fillStyle = tileParallaxLayer.context.createLinearGradient(0,0,0,tileParallaxLayer.canvas.height = parallaxSize.y); + gradient.addColorStop(0,layerColor.rgba()); + gradient.addColorStop(1,layerColor.subtract(new Color(1,1,1,0)).mutate(.1).clamp().rgba()); + + for(let x=parallaxSize.x;x--;) + { + // pull slope towards start ground level + tileParallaxLayer.context.fillRect(x,groundLevel += groundSlope = rand() < .05 ? rand(1,-1) : + groundSlope + (startGroundLevel - groundLevel)/2e3,1,parallaxSize.y) + } + } +} + +function updateParallaxLayers() +{ + tileParallaxLayers.forEach((tileParallaxLayer, i)=> + { + const distance = 4+i; + const parallax = vec2(150,30).scale((i*i+1)); + const cameraDeltaFromCenter = cameraPos.subtract(levelSize.scale(.5)).divide(levelSize.scale(-.5).divide(parallax)); + tileParallaxLayer.scale = vec2(distance/cameraScale); + tileParallaxLayer.pos = cameraPos + .subtract(tileParallaxLayer.size.multiply(tileParallaxLayer.scale).scale(.5)) + .add(cameraDeltaFromCenter.scale(1/cameraScale)) + .subtract(vec2(0,150/cameraScale)) + }); +} \ No newline at end of file diff --git a/Games/Space_Dominators/appLevel.js b/Games/Space_Dominators/appLevel.js new file mode 100644 index 0000000000..d0fe1f4743 --- /dev/null +++ b/Games/Space_Dominators/appLevel.js @@ -0,0 +1,532 @@ +/* + Javascript Space Game + By Nishant kaushal + +*/ + +'use strict'; + +const tileType_ladder = -1; +const tileType_empty = 0; +const tileType_solid = 1; +const tileType_dirt = 2; +const tileType_base = 3; +const tileType_pipeH = 4; +const tileType_pipeV = 5; +const tileType_glass = 6; +const tileType_baseBack= 7; +const tileType_window = 8; + +const tileRenderOrder = -1e3; +const tileBackgroundRenderOrder = -2e3; + +// level objects +let players=[], playerLives, tileLayer, tileBackgroundLayer, totalKills; + +// level settings +let levelSize, level, levelSeed, levelEnemyCount, levelWarmup; +let levelColor, levelBackgroundColor, levelSkyColor, levelSkyHorizonColor, levelGroundColor; +let skyParticles, skyRain, skySoundTimer = new Timer; +let gameTimer = new Timer, levelTimer = new Timer, levelEndTimer = new Timer; + +let tileBackground; +const setTileBackgroundData = (pos, data=0)=> + pos.arrayCheck(tileCollisionSize) && (tileBackground[(pos.y|0)*tileCollisionSize.x+pos.x|0] = data); +const getTileBackgroundData = (pos)=> + pos.arrayCheck(tileCollisionSize) ? tileBackground[(pos.y|0)*tileCollisionSize.x+pos.x|0] : 0; + +/////////////////////////////////////////////////////////////////////////////// +// level generation + +const resetGame=()=> +{ + levelEndTimer.unset(); + gameTimer.set(totalKills = level = 0); + nextLevel(playerLives = 6); +} + +function buildTerrain(size) +{ + tileBackground = []; + initTileCollision(size); + let startGroundLevel = rand(40, 60); + let groundLevel = startGroundLevel; + let groundSlope = rand(.5,-.5); + let canayonWidth = 0, backgroundDelta = 0, backgroundDeltaSlope = 0; + for(let x=0; x < size.x; x++) + { + // pull slope towards start ground level + groundLevel += groundSlope = rand() < .05 ? rand(.5,-.5) : + groundSlope + (startGroundLevel - groundLevel)/1e3; + + // small jump + if (rand() < .04) + groundLevel += rand(9,-9); + + if (rand() < .03) + { + // big jump + const jumpDelta = rand(9,-9); + startGroundLevel = clamp(startGroundLevel + jumpDelta, 80, 20); + groundLevel += jumpDelta; + groundSlope = rand(.5,-.5); + } + + --canayonWidth; + if (rand() < .005) + canayonWidth = rand(7, 2); + + backgroundDelta += backgroundDeltaSlope; + if (rand() < .1) + backgroundDelta = rand(3, -1); + if (rand() < .1) + backgroundDelta = 0; + if (rand() < .1) + backgroundDeltaSlope = rand(1,-1); + backgroundDelta = clamp(backgroundDelta, 3, -1) + + groundLevel = clamp(groundLevel, 99, 30); + for(let y=0; y < size.y; y++) + { + const pos = vec2(x,y); + + let frontTile = tileType_empty; + if (y < groundLevel && canayonWidth <= 0) + frontTile = tileType_dirt; + + let backTile = tileType_empty; + if (y < groundLevel + backgroundDelta) + backTile = tileType_dirt; + + setTileCollisionData(pos, frontTile); + setTileBackgroundData(pos, backTile); + } + } + + // add random holes + for(let i=levelSize.x; i--;) + { + const pos = vec2(rand(levelSize.x), rand(levelSize.y-19, 19)); + for(let x = rand(9,1)|0;--x;) + for(let y = rand(9,1)|0;--y;) + setTileCollisionData(pos.add(vec2(x,y)), tileType_empty); + } +} + +function spawnProps(pos) +{ + if (abs(checkpointPos.x-pos.x) > 5) + { + new Prop(pos); + const propPlaceSize = .51; + if (randSeeded() < .2) + { + // 3 triangle prop stack + new Prop(pos.add(vec2(propPlaceSize*2,0))); + if (randSeeded() < .2) + new Prop(pos.add(vec2(propPlaceSize,propPlaceSize*2))); + } + else if (randSeeded() < .2) + { + // 3 column prop stack + new Prop(pos.add(vec2(0,propPlaceSize*2))); + if (randSeeded() < .2) + new Prop(pos.add(vec2(0,propPlaceSize*4))); + } + } +} + +function buildBase() +{ + let raycastHit; + for(let tries=99;!raycastHit;) + { + if (!tries--) + return 1; // count not find pos + + const pos = vec2(randSeeded(levelSize.x-40,40), levelSize.y); + + // must not be near player start + if (abs(checkpointPos.x-pos.x) > 30) + raycastHit = tileCollisionRaycast(pos, vec2(pos.x, 0)); + } + + const cave = rand() < .5; + const baseBottomCenterPos = raycastHit.int(); + const baseSize = randSeeded(20,9)|0; + const baseFloors = cave? 1 : randSeeded(6,1)|0; + const basementFloors = randSeeded(cave?7:4, 0)|0; + let floorBottomCenterPos = baseBottomCenterPos.subtract(vec2(0,basementFloors*6)); + floorBottomCenterPos.y = max(floorBottomCenterPos.y, 9); // prevent going through bottom + + let floorWidth = baseSize; + let previousFloorHeight = 0; + for(let floor=-basementFloors; floor <= baseFloors; ++floor) + { + const topFloor = floor == baseFloors; + const groundFloor = !floor; + const isCaveFloor = cave ? rand() < .8 | (floor == 0 && rand() < .6): 0; + let floorHeight = isCaveFloor ? randSeeded(9,2)|0 : topFloor? 0 : groundFloor? randSeeded(9,4)|0 : randSeeded(7,2)|0; + const floorSpace = topFloor ? 4 : max(floorHeight - 1, 0); + + let backWindow = rand() < .5; + const windowTop = rand(4,2); + + for(let x=-floorWidth; x <= floorWidth; ++x) + { + const isWindow = !isCaveFloor && randSeeded() < .3; + const hasSide = !isCaveFloor && randSeeded() < .9; + + if (cave) + backWindow = 0; + else if (rand() < .1) + backWindow = !backWindow; + + if (cave && rand() < .2) + floorHeight = clamp(floorHeight + rand(3,-3)|0, 9, 2) + + for(let y=-1; y < floorHeight; ++y) + { + const pos = floorBottomCenterPos.add(vec2(x,y)); + let foregroundTile = tileType_empty; + if (isCaveFloor) + { + // add ceiling and floor + if ( y < 0 | y == floorHeight-1) + foregroundTile = tileType_dirt; + + setTileBackgroundData(pos, tileType_dirt); + setTileCollisionData(pos, foregroundTile); + } + else + { + // add ceiling and floor + const isHorizontal = y < 0 | y == floorHeight-1; + if (isHorizontal) + foregroundTile = tileType_pipeH; + + // add walls and windows + if (abs(x) == floorWidth) + foregroundTile = isHorizontal ? tileType_base : isWindow ? tileType_glass : tileType_pipeV; + + let backgroundTile = foregroundTile>0||floorHeight<3? tileType_baseBack : tileType_base; + if (backWindow && y > 0 && y < floorHeight-windowTop && abs(x) < floorWidth-2) + backgroundTile = tileType_window; + + setTileBackgroundData(pos, backgroundTile); + setTileCollisionData(pos, foregroundTile); + } + } + } + + // add ladders to floor below + if (!cave || !topFloor) + for(let ladderCount=randSeeded(2)+1|0;ladderCount--;) + { + const x = randSeeded(floorWidth-1, -floorWidth+1)|0; + const pos = floorBottomCenterPos.add(vec2(x,-2)); + + let y=0; + let hitBottom = 0; + for(; y < levelSize.y; ++y) + { + const pos = floorBottomCenterPos.add(vec2(x,-y-1)); + if (pos.y < 2) + { + // hit bottom, no ladder + break; + } + if (y && getTileCollisionData(pos) > 0 && getTileCollisionData(pos.add(vec2(0,1))) <= 0 ) + { + for(;y--;) + { + const pos = floorBottomCenterPos.add(vec2(x,-y-1)); + setTileCollisionData(pos, tileType_ladder); + } + break; + } + } + } + + // spawn crates + const propCount = randSeeded(floorWidth/2)|0; + for(let i = propCount; i--;) + spawnProps(floorBottomCenterPos.add(vec2(randSeeded( floorWidth-2,-floorWidth+2),.5))); + + if (topFloor || floorSpace > 1) + { + // spawn enemies + for(let i = propCount; i--;) + { + const pos = floorBottomCenterPos.add(vec2(randSeeded( floorWidth-1,-floorWidth+1),.7)); + new Enemy(pos); + } + } + + const oldFloorWidth = floorWidth; + floorWidth = max(floorWidth + randSeeded(8,-8),9)|0; + floorBottomCenterPos.y += floorHeight; + floorBottomCenterPos.x += randSeeded(oldFloorWidth - floorWidth+1)|0; + previousFloorHeight = floorHeight; + } + + //checkpointPos = floorBottomCenterPos.copy(); // start player on base for testing + + // spawn random enemies and props + for(let i=20;levelEnemyCount>0&&i--;) + { + const pos = vec2(floorBottomCenterPos.x + randSeeded(99, -99), levelSize.y); + raycastHit = tileCollisionRaycast(pos, vec2(pos.x, 0)); + // must not be near player start + if (raycastHit && abs(checkpointPos.x-pos.x) > 20) + { + const pos = raycastHit.add(vec2(0,2)); + randSeeded() < .7 ? new Enemy(pos) : spawnProps(pos); + } + } +} + +function generateLevel() +{ + levelEndTimer.unset(); + + // remove all objects that are not persistnt or are descendants of something persitant + for(const o of engineObjects) + o.destroy(); + engineObjects = []; + engineCollideObjects = []; + + // randomize ground level hills + buildTerrain(levelSize); + + // find starting poing for player + let raycastHit; + for(let tries=99;!raycastHit;) + { + if (!tries--) + return 1; // count not find pos + + // start on either side of level + checkpointPos = vec2(levelSize.x/2 + (levelSize.x/2-10-randSeeded(9))*(randSeeded()<.5?-1:1) | 0, levelSize.y); + raycastHit = tileCollisionRaycast(checkpointPos, vec2(checkpointPos.x, 0)); + } + checkpointPos = raycastHit.add(vec2(0,1)); + + // random bases until there enough enemies + for(let tries=99;levelEnemyCount>0;) + { + if (!tries--) + return 1; // count not spawn enemies + + if (buildBase()) + return 1; + } + + // build checkpoints + for(let x=0; x 50) + { + // todo prevent overhangs + const pos = raycastHit.add(vec2(0,1)); + new Checkpoint(pos); + } + } +} + +const groundTileStart = 8; + +function makeTileLayers(level_) +{ + // create foreground layer + tileLayer = new TileLayer(vec2(), levelSize); + tileLayer.renderOrder = tileRenderOrder; + + // create background layer + tileBackgroundLayer = new TileLayer(vec2(), levelSize); + tileBackgroundLayer.renderOrder = tileBackgroundRenderOrder; + + for(let x=levelSize.x;x--;) + for(let y=levelSize.y;y--;) + { + const pos = vec2(x,y); + let tileType = getTileCollisionData(pos); + if (tileType) + { + // todo pick tile, direction etc based on neighbors tile type + let direction = rand(4)|0 + let mirror = rand(2)|0; + let color; + + let tileIndex = groundTileStart; + if (tileType == tileType_dirt) + { + tileIndex = groundTileStart+2 + rand()**3*2|0; + color = levelColor.mutate(.03); + } + else if (tileType == tileType_pipeH) + { + tileIndex = groundTileStart+5; + direction = 1; + } + else if (tileType == tileType_pipeV) + { + tileIndex = groundTileStart+5; + direction = 0; + } + else if (tileType == tileType_glass) + { + tileIndex = groundTileStart+5; + direction = 0; + color = new Color(0,1,1,.5); + } + else if (tileType == tileType_base) + tileIndex = groundTileStart+4; + else if (tileType == tileType_ladder) + { + tileIndex = groundTileStart+7; + direction = mirror = 0; + } + tileLayer.setData(pos, new TileLayerData(tileIndex, direction, mirror, color)); + } + + tileType = getTileBackgroundData(pos); + if (tileType) + { + // todo pick tile, direction etc based on neighbors tile type + const direction = rand(4)|0 + const mirror = rand(2)|0; + let color = new Color(); + + let tileIndex = groundTileStart; + if (tileType == tileType_dirt) + { + tileIndex = groundTileStart +2 + rand()**3*2|0; + color = levelColor.mutate(); + } + else if (tileType == tileType_base) + { + tileIndex = groundTileStart+6; + color = color.scale(rand(1,.7),1) + } + else if (tileType == tileType_baseBack) + { + tileIndex = groundTileStart+6; + color = color.scale(rand(.5,.3),1).mutate(); + } + else if (tileType == tileType_window) + { + tileIndex = 0; + color = new Color(0,1,1,.5); + } + tileBackgroundLayer.setData(pos, new TileLayerData(tileIndex, direction, mirror, color.scale(.4,1))); + } + } + tileLayer.redraw(); + tileBackgroundLayer.redraw(); +} + +function applyArtToLevel() +{ + makeTileLayers(); + + // apply decoration to level tiles + for(let x=levelSize.x;x--;) + for(let y=levelSize.y;--y;) + { + decorateBackgroundTile(vec2(x,y)); + decorateTile(vec2(x,y)); + } + + generateParallaxLayers(); + + if (precipitationEnable && !lowGraphicsSettings) + { + // create rain or snow particles + if (skyRain = rand() < .5) + { + // rain + skyParticles = new ParticleEmitter( + vec2(), 3, 0, 0, .3, // pos, emitSize, emitTime, emitRate, emiteCone + 0, undefined, // tileIndex, tileSize + new Color(.8,1,1,.6), new Color(.5,.5,1,.2), // colorStartA, colorStartB + new Color(.8,1,1,.6), new Color(.5,.5,1,.2), // colorEndA, colorEndB + 2, .1, .1, .2, 0, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed + .99, 1, .5, PI, .2, // damping, angleDamping, gravityScale, particleCone, fadeRate, + .5, 1 // randomness, collide, additive, randomColorLinear, renderOrder + ); + skyParticles.elasticity = .2; + skyParticles.trailScale = 2; + } + else + { + // snow + skyParticles = new ParticleEmitter( + vec2(), 3, 0, 0, .5, // pos, emitSize, emitTime, emitRate, emiteCone + 0, undefined, // tileIndex, tileSize + new Color(1,1,1,.8), new Color(1,1,1,.2), // colorStartA, colorStartB + new Color(1,1,1,.8), new Color(1,1,1,.2), // colorEndA, colorEndB + 3, .1, .1, .3, .01, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed + .98, 1, .2, PI, .2, // damping, angleDamping, gravityScale, particleCone, fadeRate, + .5, 1 // randomness, collide, additive, randomColorLinear, renderOrder + ); + } + skyParticles.emitRate = precipitationEnable && rand()<.5 ? rand(500) : 0; + skyParticles.angle = PI+rand(.5,-.5); + } +} + +function nextLevel() +{ + playerLives += 4; // three for beating a level plus 1 for respawning + levelEnemyCount = 15 + min(level * 30, 300); + ++level; + levelSeed = randSeed = rand(1e9)|0; + levelSize = vec2(min(level*99,400),200); + levelColor = randColor(new Color(.2,.2,.2), new Color(.8,.8,.8)); + levelSkyColor = randColor(new Color(.5,.5,.5), new Color(.9,.9,.9)); + levelSkyHorizonColor = levelSkyColor.subtract(new Color(.05,.05,.05)).mutate(.3).clamp(); + levelGroundColor = levelColor.mutate().add(new Color(.3,.3,.3)).clamp(); + + // keep trying until a valid level is generated + for(;generateLevel();); + + // warm up level + levelWarmup = 1; + + // objects that effect the level must be added here + const firstCheckpoint = new Checkpoint(checkpointPos).setActive(); + + applyArtToLevel(); + + const warmUpTime = 2; + for(let i=warmUpTime * FPS; i--;) + { + updateSky(); + engineUpdateObjects(); + } + levelWarmup = 0; + + // destroy any objects that are stuck in collision + forEachObject(0, 0, (o)=> + { + if (o.isGameObject && o != firstCheckpoint) + { + const checkBackground = o.isCheckpoint; + (checkBackground ? getTileBackgroundData(o.pos) > 0 : tileCollisionTest(o.pos,o.size)) && o.destroy(); + } + }); + + // hack, subtract off warm up time from main game timer + //gameTimer.time += warmUpTime; + levelTimer.set(); + + // spawn player + players = []; + new Player(checkpointPos); + //new Enemy(checkpointPos.add(vec2(3))); // test enemy +} \ No newline at end of file diff --git a/Games/Space_Dominators/appObjects.js b/Games/Space_Dominators/appObjects.js new file mode 100644 index 0000000000..244426e8f4 --- /dev/null +++ b/Games/Space_Dominators/appObjects.js @@ -0,0 +1,580 @@ +/* + Javascript Space Game + By Nishant kaushal + +*/ + +'use strict'; + +class GameObject extends EngineObject +{ + constructor(pos, size, tileIndex, tileSize, angle) + { + super(pos, size, tileIndex, tileSize, angle); + this.isGameObject = 1; + this.health = this.healthMax = 0; + this.burnDelay = .1; + this.burnTime = 3; + this.damageTimer = new Timer; + this.burnDelayTimer = new Timer; + this.burnTimer = new Timer; + this.extinguishTimer = new Timer; + this.color = new Color; + this.additiveColor = new Color(0,0,0,0); + } + + inUpdateWindow() { return levelWarmup || isOverlapping(this.pos, this.size, cameraPos, updateWindowSize); } + + update() + { + if (this.parent || this.persistent || !this.groundObject || this.inUpdateWindow()) // pause physics if outside update window + super.update(); + + if (!this.isLavaRock) + { + if (!this.isDead() && this.damageTimer.isSet()) + { + // flash white when damaged + const a = .5*percent(this.damageTimer.get(), 0, .15); + this.additiveColor = new Color(a,a,a,0); + } + else + this.additiveColor = new Color(0,0,0,0); + } + + if (!this.parent && this.pos.y < -1) + { + // kill and destroy if fall below level + this.kill(); + this.persistent || this.destroy(); + } + else if (this.burnTime) + { + if (this.burnTimer.isSet()) + { + // burning + if (this.burnTimer.elapsed()) + { + this.kill(); + if (this.fireEmitter) + this.fireEmitter.emitRate = 0; + } + else if (rand() < .01) + { + // random chance to spread fire + const spreadRadius = 2; + debugFire && debugCircle(this.pos, spreadRadius, '#f00', 1); + forEachObject(this.pos, spreadRadius, (o)=>o.isGameObject && o.burn()); + } + } + else if (this.burnDelayTimer.elapsed()) + { + // finished waiting to burn + this.burn(1); + } + } + } + + render() + { + drawTile(this.pos, this.size, this.tileIndex, this.tileSize, this.color.scale(this.burnColorPercent(),1), this.angle, this.mirror, this.additiveColor); + } + + burnColorPercent() { return lerp(this.burnTimer.getPercent(), .2, 1); } + + burn(instant) + { + if (!this.canBurn || this.burnTimer.isSet() || this.extinguishTimer.active()) + return; + + if (godMode && this.isPlayer) + return; + + if (this.team == team_player) + { + // safety window after spawn + if (godMode || this.getAliveTime() < 2) + return; + } + + if (instant) + { + this.burnTimer.set(this.burnTime*rand(1.5, 1)); + this.fireEmitter = makeFire(); + this.addChild(this.fireEmitter); + } + else + this.burnDelayTimer.isSet() || this.burnDelayTimer.set(this.burnDelay*rand(1.5, 1)); + } + + extinguish() + { + if (this.fireEmitter && this.fireEmitter.emitRate == 0) + return; + + // stop burning + this.extinguishTimer.set(.1); + this.burnTimer.unset(); + this.burnDelayTimer.unset(); + if (this.fireEmitter) + this.fireEmitter.destroy(); + this.fireEmitter = 0; + } + + heal(health) + { + assert(health >= 0); + if (this.isDead()) + return 0; + + // apply healing and return amount healed + return this.health - (this.health = min(this.health + health, this.healthMax)); + } + + damage(damage, damagingObject) + { + ASSERT(damage >= 0); + if (this.isDead()) + return 0; + + // set damage timer; + this.damageTimer.set(); + for(const child of this.children) + child.damageTimer && child.damageTimer.set(); + + // apply damage and kill if necessary + const newHealth = max(this.health - damage, 0); + if (!newHealth) + this.kill(damagingObject); + + // set new health and return amount damaged + return this.health - (this.health = newHealth); + } + + isDead() { return !this.health; } + kill(damagingObject) { this.destroy(); } + + collideWithObject(o) + { + if (o.isLavaRock && this.canBurn) + { + if (levelWarmup) + { + this.destroy(); + return 1; + } + this.burn(); + } + return 1; + } +} + +/////////////////////////////////////////////////////////////////////////////// + +const propType_crate_wood = 0; +const propType_crate_explosive = 1; +const propType_crate_metal = 2; +const propType_barrel_explosive = 3; +const propType_barrel_water = 4; +const propType_barrel_metal = 5; +const propType_barrel_highExplosive = 6; +const propType_rock = 7; +const propType_rock_lava = 8; +const propType_count = 9; + +class Prop extends GameObject +{ + constructor(pos, typeOverride) + { + super(pos); + + const type = this.type = (typeOverride != undefined ? typeOverride : rand()**2*propType_count|0); + let health = 5; + this.tileIndex = 16; + this.explosionSize = 0; + if (this.type == propType_crate_wood) + { + this.color = new Color(1,.5,0); + this.canBurn = 1; + } + else if (this.type == propType_crate_metal) + { + this.color = new Color(.9,.9,1); + health = 10; + } + else if (this.type == propType_crate_explosive) + { + this.color = new Color(.2,.8,.2); + this.canBurn = 1; + this.explosionSize = 2; + health = 1e3; + } + else if (this.type == propType_barrel_metal) + { + this.tileIndex = 17; + this.color = new Color(.9,.9,1); + health = 10; + } + else if (this.type == propType_barrel_explosive) + { + this.tileIndex = 17; + this.color = new Color(.2,.8,.2); + this.canBurn = 1; + this.explosionSize = 2; + health = 1e3; + } + else if (this.type == propType_barrel_highExplosive) + { + this.tileIndex = 17; + this.color = new Color(1,.1,.1); + this.canBurn = 1; + this.explosionSize = 3; + this.burnTimeDelay = 0; + this.burnTime = rand(.5,.1); + health = 1e3; + } + else if (this.type == propType_barrel_water) + { + this.tileIndex = 17; + this.color = new Color(0,.6,1); + health = .01; + } + else if (this.type == propType_rock || this.type == propType_rock_lava) + { + this.tileIndex = 18; + this.color = new Color(.8,.8,.8).mutate(.2); + health = 30; + this.mass *= 4; + if (rand() < .2) + { + health = 99; + this.mass *= 4; + this.size = this.size.scale(2); + this.pos.y += .5; + } + this.isCrushing = 1; + + if (this.type == propType_rock_lava) + { + this.color = new Color(1,.9,0); + this.additiveColor = new Color(1,0,0); + this.isLavaRock = 1; + } + } + + // randomly angle and flip axis (90 degree rotation) + this.angle = (rand(4)|0)*PI/2; + if (rand() < .5) + this.size = this.size.flip(); + + this.mirror = rand() < .5; + this.health = this.healthMax = health; + this.setCollision(1, 1); + } + + update() + { + const oldVelocity = this.velocity.copy(); + super.update(); + + // apply collision damage + const deltaSpeedSquared = this.velocity.subtract(oldVelocity).lengthSquared(); + deltaSpeedSquared > .05 && this.damage(2*deltaSpeedSquared); + } + + damage(damage, damagingObject) + { + (this.explosionSize || this.type == propType_crate_wood && rand() < .1) && this.burn(); + super.damage(damage, damagingObject); + } + + kill() + { + if (this.destroyed) return; + + if (this.type == propType_barrel_water) + makeWater(this.pos); + + this.destroy(); + makeDebris(this.pos, this.color.scale(this.burnColorPercent(),1)); + + this.explosionSize ? + explosion(this.pos, this.explosionSize) : + playSound(sound_destroyTile, this.pos); + } +} + +/////////////////////////////////////////////////////////////////////////////// + +let checkpointPos, activeCheckpoint, checkpointTimer = new Timer; + +class Checkpoint extends GameObject +{ + constructor(pos) + { + super(pos.int().add(vec2(.5))) + this.renderOrder = tileRenderOrder-1; + this.isCheckpoint = 1; + for(let x=3;x--;) + for(let y=6;y--;) + setTileCollisionData(pos.subtract(vec2(x-1,1-y)), y ? tileType_empty : tileType_solid); + } + + update() + { + if (!this.inUpdateWindow()) + return; // ignore offscreen objects + + // check if player is near + for(const player of players) + player && !player.isDead() && this.pos.distanceSquared(player.pos) < 1 && this.setActive(); + } + + setActive() + { + if (activeCheckpoint != this && !levelWarmup) + playSound(sound_checkpoint, this.pos); + + checkpointPos = this.pos; + activeCheckpoint = this; + checkpointTimer.set(.1); + } + + render() + { + // draw flag + const height = 4; + const color = activeCheckpoint == this ? new Color(1,0,0) : new Color; + const a = Math.sin(time*4+this.pos.x); + drawTile(this.pos.add(vec2(.5,height-.3-.5-.03*a)), vec2(1,.6), 14, undefined, color, a*.06); + drawRect(this.pos.add(vec2(0,height/2-.5)), vec2(.1,height), new Color(.9,.9,.9)); + } +} + +/////////////////////////////////////////////////////////////////////////////// + +class Grenade extends GameObject +{ + constructor(pos) + { + super(pos, vec2(.2), 5, vec2(8)); + + this.health = this.healthMax = 1e3; + this.beepTimer = new Timer(1); + this.elasticity = .3; + this.friction = .9; + this.angleDamping = .96; + this.renderOrder = 1e8; + this.setCollision(); + } + + update() + { + super.update(); + + if (this.getAliveTime() > 3) + { + explosion(this.pos, 3); + this.destroy(); + return; + } + + if (this.beepTimer.elapsed()) + { + playSound(sound_grenade, this.pos) + this.beepTimer.set(1); + } + + alertEnemies(this.pos, this.pos); + } + + render() + { + drawTile(this.pos, vec2(.5), this.tileIndex, this.tileSize, this.color, this.angle); + + const a = this.getAliveTime(); + setBlendMode(1); + drawTile(this.pos, vec2(2), 0, vec2(16), new Color(1,0,0,.2-.2*Math.cos(a*2*PI))); + drawTile(this.pos, vec2(1), 0, vec2(16), new Color(1,0,0,.2-.2*Math.cos(a*2*PI))); + drawTile(this.pos, vec2(.5), 0, vec2(16), new Color(1,1,1,.2-.2*Math.cos(a*2*PI))); + setBlendMode(0); + } +} + +/////////////////////////////////////////////////////////////////////////////// + +class Weapon extends EngineObject +{ + constructor(pos, parent) + { + super(pos, vec2(.6), 4, vec2(8)); + + // weapon settings + this.isWeapon = 1; + this.fireTimeBuffer = this.localAngle = 0; + this.recoilTimer = new Timer; + + this.addChild(this.shellEmitter = new ParticleEmitter( + vec2(), 0, 0, 0, .1, // pos, emitSize, emitTime, emitRate, emiteCone + undefined, undefined, // tileIndex, tileSize + new Color(1,.8,.5), new Color(.9,.7,.5), // colorStartA, colorStartB + new Color(1,.8,.5), new Color(.9,.7,.5), // colorEndA, colorEndB + 3, .1, .1, .15, .1, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed + 1, .95, 1, 0, 0, // damping, angleDamping, gravityScale, particleCone, fadeRate, + .1, 1 // randomness, collide, additive, randomColorLinear, renderOrder + )); + this.shellEmitter.elasticity = .5; + this.shellEmitter.particleDestroyCallback = persistentParticleDestroyCallback; + this.renderOrder = parent.renderOrder+1; + + parent.weapon = this; + parent.addChild(this, this.localOffset = vec2(.55,0)); + } + + update() + { + super.update(); + + const fireRate = 8; + const bulletSpeed = .5; + const spread = .1; + + this.mirror = this.parent.mirror; + this.fireTimeBuffer += timeDelta; + + if (this.recoilTimer.active()) + this.localAngle = lerp(this.recoilTimer.getPercent(), 0, this.localAngle); + + if (this.triggerIsDown) + { + // slow down enemy bullets + const speed = bulletSpeed * (this.parent.isPlayer ? 1 : .5); + const rate = 1/fireRate; + for(; this.fireTimeBuffer > 0; this.fireTimeBuffer -= rate) + { + this.localAngle = -rand(.2,.15); + this.recoilTimer.set(rand(.4,.3)); + const bullet = new Bullet(this.pos, this.parent); + const direction = vec2(this.getMirrorSign(speed), 0); + bullet.velocity = direction.rotate(rand(spread,-spread)); + + this.shellEmitter.localAngle = -.8*this.getMirrorSign(); + this.shellEmitter.emitParticle(); + playSound(sound_shoot, this.pos); + + // alert enemies + this.parent.isPlayer && alertEnemies(this.pos, this.pos); + } + } + else + this.fireTimeBuffer = min(this.fireTimeBuffer, 0); + } +} + +/////////////////////////////////////////////////////////////////////////////// + +class Bullet extends EngineObject +{ + constructor(pos, attacker) + { + super(pos, vec2(0)); + this.color = new Color(1,1,0,1); + this.lastVelocity = this.velocity; + this.setCollision(); + + this.damage = this.damping = 1; + this.gravityScale = 0; + this.attacker = attacker; + this.team = attacker.team; + this.renderOrder = 1e9; + this.range = 8; + } + + update() + { + this.lastVelocity = this.velocity; + super.update(); + + this.range -= this.velocity.length(); + if (this.range < 0) + { + const emitter = new ParticleEmitter( + this.pos, .2, .1, 100, PI, // pos, emitSize, emitTime, emitRate, emiteCone + 0, undefined, // tileIndex, tileSize + new Color(1,1,0,.5), new Color(1,1,1,.5), // colorStartA, colorStartB + new Color(1,1,0,0), new Color(1,1,1,0), // colorEndA, colorEndB + .1, .5, .1, .1, .1, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed + 1, 1, .5, PI, .1, // damping, angleDamping, gravityScale, particleCone, fadeRate, + .5, 0, 1 // randomness, collide, additive, randomColorLinear, renderOrder + ); + + this.destroy(); + return; + } + + // check if hit someone + forEachObject(this.pos, this.size, (o)=> + { + if (o.isGameObject && !o.parent && o.team != this.team) + if (!o.dodgeTimer || !o.dodgeTimer.active()) + this.collideWithObject(o) + }); + } + + collideWithObject(o) + { + if (o.isGameObject) + { + o.damage(this.damage, this); + o.applyForce(this.velocity.scale(.1)); + if (o.isCharacter) + { + playSound(sound_walk, this.pos); + this.destroy(); + } + else + this.kill(); + } + + return 1; + } + + collideWithTile(data, pos) + { + if (data <= 0) + return 0; + + const destroyTileChance = data == tileType_glass ? 1 : data == tileType_dirt ? .2 : .05; + rand() < destroyTileChance && destroyTile(pos); + this.kill(); + + return 1; + } + + kill() + { + if (this.destroyed) + return; + + const emitter = new ParticleEmitter( + this.pos, 0, .1, 100, .5, // pos, emitSize, emitTime, emitRate, emiteCone + undefined, undefined, // tileIndex, tileSize + new Color(1,1,0), new Color(1,0,0), // colorStartA, colorStartB + new Color(1,1,0), new Color(1,0,0), // colorEndA, colorEndB + .2, .2, 0, .1, .1, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed + 1, 1, .5, PI, .1, // damping, angleDamping, gravityScale, particleCone, fadeRate, + .5, 1, 1 // randomness, collide, additive, randomColorLinear, renderOrder + ); + emitter.trailScale = 1; + emitter.angle = this.lastVelocity.angle() + PI; + emitter.elasticity = .3; + + this.destroy(); + } + + render() + { + drawRect(this.pos, vec2(.4,.5), new Color(1,1,1,.5), this.velocity.angle()); + drawRect(this.pos, vec2(.2,.5), this.color, this.velocity.angle()); + } +} \ No newline at end of file diff --git a/Games/Space_Dominators/engine/build/build.bat b/Games/Space_Dominators/engine/build/build.bat new file mode 100644 index 0000000000..629d0d49ab --- /dev/null +++ b/Games/Space_Dominators/engine/build/build.bat @@ -0,0 +1,98 @@ +rem SIMPLE BUILD SCRIPT FOR by Nishant kaushal +rem minfies and combines index.html and index.js and zips the result + +set name=app + +rem go to top of project +cd .. +cd .. + +rem remove old files +del %name%.zip index.min.html +rmdir /s /q build + +rem combine code +mkdir build +type engine\engineUtil.js >> build\index.js +echo.>> build\index.js +type engine\build\engineBuild.js >> build\index.js +echo.>> build\index.js +type engine\engine.js >> build\index.js +echo.>> build\index.js +type engine\engineAudio.js >> build\index.js +echo.>> build\index.js +type engine\engineObject.js >> build\index.js +echo.>> build\index.js +type engine\engineTileLayer.js >> build\index.js +echo.>> build\index.js +type engine\engineInput.js >> build\index.js +echo.>> build\index.js +type engine\engineParticle.js >> build\index.js +echo.>> build\index.js +type engine\engineWebGL.js >> build\index.js +echo.>> build\index.js +type engine\engineDraw.js >> build\index.js +echo.>> build\index.js + +rem add app files to include here +type appObjects.js >> build\index.js +echo.>> build\index.js +type appCharacters.js >> build\index.js +echo.>> build\index.js +type appEffects.js >> build\index.js +echo.>> build\index.js +type appLevel.js >> build\index.js +echo.>> build\index.js +type app.js >> build\index.js +echo.>> build\index.js + +rem minify code with closure +call google-closure-compiler --js build\index.js --js_output_file build\index.js --compilation_level ADVANCED --language_out ECMASCRIPT_2019 --warning_level VERBOSE --jscomp_off * --assume_function_wrapper +if %ERRORLEVEL% NEQ 0 ( + pause + exit /b %ERRORLEVEL% +) + +rem more minification with uglify or terser (they both are about the same) +call uglifyjs -o build\index.js --compress --mangle -- build\index.js +rem call terser -o build\index.js --compress --mangle -- build\index.js +if %ERRORLEVEL% NEQ 0 ( + pause + exit /b %ERRORLEVEL% +) + +rem roadroaller compresses the code better then zip +call roadroller build\index.js -o build\index.js +if %ERRORLEVEL% NEQ 0 ( + pause + exit /b %ERRORLEVEL% +) + +rem build the html +type engine\build\index.html >> build\index.html +echo ^ >> build\index.html +type build\index.js >> build\index.html +echo ^ >> build\index.html + +rem minify the png +call imagemin tiles.png > build\tiles.png +if %ERRORLEVEL% NEQ 0 ( + pause + exit /b %ERRORLEVEL% +) + +rem zip the result +cd build +rem call advzip -a -4 -i 99 ..\%name%.zip index.html +call ..\ect -9 -strip -zip ..\%name%.zip index.html +if %ERRORLEVEL% NEQ 0 ( + pause + exit /b %ERRORLEVEL% +) + +rem remove build folder +copy index.html ..\index.min.html +cd .. +rmdir /s /q build + +rem pause to see result ect -9 -strip -zip .zip index.html \ No newline at end of file diff --git a/Games/Space_Dominators/engine/build/build.html b/Games/Space_Dominators/engine/build/build.html new file mode 100644 index 0000000000..1e952ee24a --- /dev/null +++ b/Games/Space_Dominators/engine/build/build.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Games/Space_Dominators/engine/build/engineBuild.js b/Games/Space_Dominators/engine/build/engineBuild.js new file mode 100644 index 0000000000..18b510d78e --- /dev/null +++ b/Games/Space_Dominators/engine/build/engineBuild.js @@ -0,0 +1,25 @@ +/* + LittleJS - Build include file + By Nishant kaushal + + This file is automatically included first by the build system. +*/ + + +'use strict'; + +const debug = 0; +const showWatermark = 0; +const godMode = 0; +const debugOverlay = 0; +const debugPhysics = 0; +const debugParticles = 0; + +// allow debug commands to be removed from the final build +const ASSERT = ()=> {} +const debugPoint = ()=> {} +const debugRect = ()=> {} +const debugLine = ()=> {} +const debugInit = ()=> {} +const debugUpdate = ()=> {} +const debugRender = ()=> {} \ No newline at end of file diff --git a/Games/Space_Dominators/engine/build/index.html b/Games/Space_Dominators/engine/build/index.html new file mode 100644 index 0000000000..1e952ee24a --- /dev/null +++ b/Games/Space_Dominators/engine/build/index.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Games/Space_Dominators/engine/build/setupBuild.bat b/Games/Space_Dominators/engine/build/setupBuild.bat new file mode 100644 index 0000000000..d2d06e5176 --- /dev/null +++ b/Games/Space_Dominators/engine/build/setupBuild.bat @@ -0,0 +1,7 @@ +rem install these command line tools if necessary +npm install -g google-closure-compiler +npm install -g terser +npm install -g uglify +npm install -g roadroller +npm install --global imagemin-cli +npm install -g advzip-bin \ No newline at end of file diff --git a/Games/Space_Dominators/engine/engine.js b/Games/Space_Dominators/engine/engine.js new file mode 100644 index 0000000000..27d13dee2a --- /dev/null +++ b/Games/Space_Dominators/engine/engine.js @@ -0,0 +1,228 @@ +/* + LittleJS - The Little JavaScript Game Engine That Can - By Nishant kaushal + + Engine Features + - Engine and debug system are separate from game code + - Object oriented with base class engine object + - Engine handles core update loop + - Base class object handles update, physics, collision, rendering, etc + - Engine helper classes and functions like Vector2, Color, and Timer + - Super fast rendering system for tile sheets + - Sound effects audio with zzfx and music with zzfxm + - Input processing system with gamepad and touchscreen support + - Tile layer rendering and collision system + - Particle effect system + - Automatically calls appInit(), appUpdate(), appUpdatePost(), appRender(), appRenderPost() + - Debug tools and debug rendering system + - Call engineInit() to start it up! +*/ + +'use strict'; + +/////////////////////////////////////////////////////////////////////////////// +// engine config + +const engineName = 'LittleJS'; +const engineVersion = 'v0.74'; +const FPS = 60, timeDelta = 1/FPS; +const defaultFont = 'arial'; // font used for text rendering +const maxWidth = 1920, maxHeight = 1200; // up to 1080p and 16:10 +const fixedWidth = 0; // native resolution +//const fixedWidth = 1280, fixedHeight = 720; // 720p +//const fixedWidth = 128, fixedHeight = 128; // PICO-8 +//const fixedWidth = 240, fixedHeight = 136; // TIC-80 + +// tile sheet settings +//const defaultTilesFilename = 'a.png'; // everything goes in one tile sheet +const defaultTileSize = vec2(16); // default size of tiles in pixels +const tileBleedShrinkFix = .3; // prevent tile bleeding from neighbors +const pixelated = 1; // use crisp pixels for pixel art + +/////////////////////////////////////////////////////////////////////////////// +// core engine + +const gravity = -.01; +let mainCanvas=0, mainContext=0, mainCanvasSize=vec2(); +let engineObjects=[], engineCollideObjects=[]; +let frame=0, time=0, realTime=0, paused=0, frameTimeLastMS=0, frameTimeBufferMS=0, debugFPS=0; +let cameraPos=vec2(), cameraScale=4*max(defaultTileSize.x, defaultTileSize.y); +let tileImageSize, tileImageSizeInverse, shrinkTilesX, shrinkTilesY, drawCount; + +const tileImage = new Image(); // the tile image used by everything +function engineInit(appInit, appUpdate, appUpdatePost, appRender, appRenderPost) +{ + // init engine when tiles load + tileImage.onload = ()=> + { + // save tile image info + tileImageSizeInverse = vec2(1).divide(tileImageSize = vec2(tileImage.width, tileImage.height)); + debug && (tileImage.onload=()=>ASSERT(1)); // tile sheet can not reloaded + shrinkTilesX = tileBleedShrinkFix/tileImageSize.x; + shrinkTilesY = tileBleedShrinkFix/tileImageSize.y; + + // setup html + document.body.appendChild(mainCanvas = document.createElement('canvas')); + document.body.style = 'margin:0;overflow:hidden;background:#000'; + mainCanvas.style = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);image-rendering:crisp-edges;image-rendering:pixelated'; // pixelated rendering + mainContext = mainCanvas.getContext('2d'); + + debugInit(); + glInit(); + appInit(); + engineUpdate(); + }; + + // main update loop + const engineUpdate = (frameTimeMS=0)=> + { + requestAnimationFrame(engineUpdate); + + if (!document.hasFocus()) + inputData[0].length = 0; // clear input when lost focus + + // prepare to update time + const realFrameTimeDeltaMS = frameTimeMS - frameTimeLastMS; + let frameTimeDeltaMS = realFrameTimeDeltaMS; + frameTimeLastMS = frameTimeMS; + realTime = frameTimeMS / 1e3; + if (debug) + frameTimeDeltaMS *= keyIsDown(107) ? 5 : keyIsDown(109) ? .2 : 1; + if (!paused) + frameTimeBufferMS += frameTimeDeltaMS; + + // update frame + mousePosWorld = screenToWorld(mousePosScreen); + updateGamepads(); + + // apply time delta smoothing, improves smoothness of framerate in some browsers + let deltaSmooth = 0; + if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9) + { + // force an update each frame if time is close enough (not just a fast refresh rate) + deltaSmooth = frameTimeBufferMS; + frameTimeBufferMS = 0; + //debug && frameTimeBufferMS < 0 && console.log('time smoothing: ' + -deltaSmooth); + } + //debug && frameTimeBufferMS < 0 && console.log('skipped frame! ' + -frameTimeBufferMS); + + // clamp incase of extra long frames (slow framerate) + frameTimeBufferMS = min(frameTimeBufferMS, 50); + + // update the frame + for (;frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / FPS) + { + // main frame update + appUpdate(); + engineUpdateObjects(); + appUpdatePost(); + debugUpdate(); + + // update input + for(let deviceInputData of inputData) + deviceInputData.map(k=> k.r = k.p = 0); + mouseWheel = 0; + } + + // add the smoothing back in + frameTimeBufferMS += deltaSmooth; + + if (fixedWidth) + { + // clear and fill window if smaller + mainCanvas.width = fixedWidth; + mainCanvas.height = fixedHeight; + + // fit to window width if smaller + const fixedAspect = fixedWidth / fixedHeight; + const aspect = innerWidth / innerHeight; + mainCanvas.style.width = aspect < fixedAspect ? '100%' : ''; + mainCanvas.style.height = aspect < fixedAspect ? '' : '100%'; + } + else + { + // fill the window + mainCanvas.width = min(innerWidth, maxWidth); + mainCanvas.height = min(innerHeight, maxHeight); + } + + // save canvas size + mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height); + mainContext.imageSmoothingEnabled = !pixelated; // disable smoothing for pixel art + + // render sort then render while removing destroyed objects + glPreRender(mainCanvas.width, mainCanvas.height); + appRender(); + engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder); + for(const o of engineObjects) + o.destroyed || o.render(); + glCopyToContext(mainContext); + appRenderPost(); + debugRender(); + + if (showWatermark) + { + // update fps + debugFPS = lerp(.05, 1e3/(realFrameTimeDeltaMS||1), debugFPS); + mainContext.textAlign = 'right'; + mainContext.textBaseline = 'top'; + mainContext.font = '1em monospace'; + mainContext.fillStyle = '#000'; + const text = engineName + ' ' + engineVersion + ' / ' + + drawCount + ' / ' + engineObjects.length + ' / ' + debugFPS.toFixed(1); + mainContext.fillText(text, mainCanvas.width-3, 3); + mainContext.fillStyle = '#fff'; + mainContext.fillText(text, mainCanvas.width-2,2); + drawCount = 0; + } + + // copy anything left in the buffer if necessary + glCopyToContext(mainContext); + } + + //tileImage.src = 'tiles.png'; + tileImage.src = +`data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAABABAMAAAAg+GJMAAAAJ1BMVEUAAAD///+AgID/AAAAAACJT6T/dwBIG11mlD2i91azs7PZ2dlAQEA9UPniAAAAAXRSTlMAQObYZgAAAoRJREFUeNqsk8dZw0AQhWfNiHDbARfgVAB0sNI3LTgcfSLcbasC0o0j6panHIYMT+FX+p8y/UdECJnNJlTn6PJnft4wEvHFasCkis2JakItl9dpemjZxgniywInnhX2hWAxXug0NIwKcduwjRTpF8hVLp7PJDSEuE5X24ofFZAQV7cAEZtDQ4jLJcSKgzuAXD8DDuVDdGOFmWjNaPN0v1zdPtU0BTSIi8cQY60ZPWcQ715r2oIzeqFupBQbZqVY0xY8PT1RN6zIh7QP8TTL+lcwmiE1megkRagif1DgvB8WsAILbQpYgZ2aD8kWsGLMUSybdfMpm1tgDRiJzfr7P5MteGufDpXchoE4jGcKj32C2oBKsM5jlPUlT4VKyN9TdFuSwKsfqrLHTcE5ucQgKL8H+GZW2n0YZptZKUXSXhIUic4nfa4OXrPPNmXTxI8BwzCBFcP6DjGxfwFmmwIwBv548/sccBJqPGRK10uYu5YCtRkD/r3xc8AEELDOk6ZAn3OHXR7h19D8OAckE3ukFiAkyb7V+lPXAqecd/8DYCSAkAGK+r37W3fhDZYCQqg4ICS/HKjNQsACbuYYRHLmHLh1BFfRHikKevd0dwATAQzTW3Dv7n8DKCrsZSF51wLh2hssfCOGQTLoegvZrn/j0iIlVEyKLSDjk0VaWGVjZERCQpHLqwywcEzIlbCChLArx1SbpQBgjITB5+f88l6bw+tLnbzW2eZWJQGEOMwKk3hz4JQnuy8FIH4tw+T2QNkNzSmeA0eA7T0BgFMc8mQox9qsCiQIY+AwDO/bNSOk4zaMIxxyvi8AMAW2xwcH1o+w/hHXf+P6RVq/yo86pjXn/PT09LTsL3dlHSBbTLmdAAAAAElFTkSuQmCC`; +} + +function engineUpdateObjects() +{ + // recursive object update + const updateObject = (o)=> + { + if (!o.destroyed) + { + o.update(); + for(const child of o.children) + updateObject(child); + } + } + for(const o of engineObjects) + o.parent || updateObject(o); + engineObjects = engineObjects.filter(o=>!o.destroyed); + engineCollideObjects = engineCollideObjects.filter(o=>!o.destroyed); + time = ++frame / FPS; +} + +function forEachObject(pos, size=0, callbackFunction=(o)=>1, collideObjectsOnly=1) +{ + const objectList = collideObjectsOnly ? engineCollideObjects : engineObjects; + if (!size) + { + // no overlap test + for (const o of objectList) + callbackFunction(o); + } + else if (size.x != undefined) + { + // aabb test + for (const o of objectList) + isOverlapping(pos, size, o.pos, o.size) && callbackFunction(o); + } + else + { + // circle test + const sizeSquared = size**2; + for (const o of objectList) + pos.distanceSquared(o.pos) < sizeSquared && callbackFunction(o); + } +} \ No newline at end of file diff --git a/Games/Space_Dominators/engine/engineAudio.js b/Games/Space_Dominators/engine/engineAudio.js new file mode 100644 index 0000000000..a6c3152300 --- /dev/null +++ b/Games/Space_Dominators/engine/engineAudio.js @@ -0,0 +1,273 @@ +/* + LittleJS Audio System + - Speech Synthesis + - ZzFX Sound Effects + - ZzFXM Music + - Can attenuate zzfx sounds by camera range +*/ + +'use strict'; + +const soundEnable = 1; // all audio can be disabled +const defaultSoundRange = 15;// distance where taper starts +const soundTaperPecent = .5; // extra range added for sound taper +const audioVolume = .5; // volume for sound, music and speech +let audioContext; // main audio context + +/////////////////////////////////////////////////////////////////////////////// + +// play a zzfx sound in world space with attenuation and culling +function playSound(zzfxSound, pos, range=defaultSoundRange, volumeScale=1) +{ + if (!soundEnable) return; + + const lengthSquared = cameraPos.distanceSquared(pos); + const maxRange = range * (soundTaperPecent + 1); + if (lengthSquared > maxRange**2) + return; + + // copy sound (so volume scale isnt permanant) + zzfxSound = [...zzfxSound]; + + // scale volume + const scale = volumeScale * percent(lengthSquared**.5, range, maxRange); + zzfxSound[0] = (zzfxSound[0]||1) * scale; + zzfx(...zzfxSound); +} + +// render and play zzfxm music with an option to loop +function playMusic(zzfxmMusic, loop=1) +{ + if (!soundEnable) return; + + const source = zzfxP(...zzfxM(...zzfxmMusic)); + source && (source.loop = loop); + return source; +} + +/////////////////////////////////////////////////////////////////////////////// +// speak text with passed in settings +function speak(text, language='', volume=1, rate=1, pitch=1) +{ + if (!soundEnable || !speechSynthesis) return; + + // common languages (not supported by all browsers) + // en - english, it - italian, fr - french, de - german, es - spanish + // ja - japanese, ru - russian, zh - chinese, hi - hindi, ko - korean + + // build utterance and speak + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = language; + utterance.volume = volume*audioVolume*3; + utterance.rate = rate; + utterance.pitch = pitch; + speechSynthesis.speak(utterance); +} + +const stopSpeech = ()=> speechSynthesis && speechSynthesis.cancel(); + +/////////////////////////////////////////////////////////////////////////////// +// ZzFXMicro - Zuper Zmall Zound Zynth - v1.1.8 by Nishant kaushal + +const zzfxR = 44100; // sample rate +function zzfx( + // parameters + volume = 1, randomness = .05, frequency = 220, attack = 0, sustain = 0, + release = .1, shape = 0, shapeCurve = 1, slide = 0, deltaSlide = 0, + pitchJump = 0, pitchJumpTime = 0, repeatTime = 0, noise = 0, modulation = 0, + bitCrush = 0, delay = 0, sustainVolume = 1, decay = 0, tremolo = 0 +) +{ + // wait for user input to create audio context + if (!soundEnable || !hadInput) return; + + // init parameters + let PI2 = PI*2, sign = v => v>0?1:-1, + startSlide = slide *= 500 * PI2 / zzfxR / zzfxR, b=[], + startFrequency = frequency *= (1 + randomness*2*Math.random() - randomness) * PI2 / zzfxR, + t=0, tm=0, i=0, j=1, r=0, c=0, s=0, f, length; + + // scale by sample rate + attack = attack * zzfxR + 9; // minimum attack to prevent pop + decay *= zzfxR; + sustain *= zzfxR; + release *= zzfxR; + delay *= zzfxR; + deltaSlide *= 500 * PI2 / zzfxR**3; + modulation *= PI2 / zzfxR; + pitchJump *= PI2 / zzfxR; + pitchJumpTime *= zzfxR; + repeatTime = repeatTime * zzfxR | 0; + + // generate waveform + for(length = attack + decay + sustain + release + delay | 0; + i < length; b[i++] = s) + { + if (!(++c%(bitCrush*100|0))) // bit crush + { + s = shape? shape>1? shape>2? shape>3? // wave shape + Math.sin((t%PI2)**3) : // 4 noise + Math.max(Math.min(Math.tan(t),1),-1): // 3 tan + 1-(2*t/PI2%2+2)%2: // 2 saw + 1-4*abs(Math.round(t/PI2)-t/PI2): // 1 triangle + Math.sin(t); // 0 sin + + s = (repeatTime ? + 1 - tremolo + tremolo*Math.sin(PI2*i/repeatTime) // tremolo + : 1) * + sign(s)*(abs(s)**shapeCurve) * // curve 0=square, 2=pointy + volume * audioVolume * ( // envelope + i < attack ? i/attack : // attack + i < attack + decay ? // decay + 1-((i-attack)/decay)*(1-sustainVolume) : // decay falloff + i < attack + decay + sustain ? // sustain + sustainVolume : // sustain volume + i < length - delay ? // release + (length - i - delay)/release * // release falloff + sustainVolume : // release volume + 0); // post release + + s = delay ? s/2 + (delay > i ? 0 : // delay + (i pitchJumpTime) // pitch jump + { + frequency += pitchJump; // apply pitch jump + startFrequency += pitchJump; // also apply to start + j = 0; // reset pitch jump time + } + + if (repeatTime && !(++r % repeatTime)) // repeat + { + frequency = startFrequency; // reset frequency + slide = startSlide; // reset slide + j = j || 1; // reset pitch jump time + } + } + + // create audio context + if (!audioContext) + audioContext = new (window.AudioContext||webkitAudioContext); + + // create buffer and source + const buffer = audioContext.createBuffer(1, b.length, zzfxR), + source = audioContext.createBufferSource(); + + // copy samples to buffer and play + buffer.getChannelData(0).set(b); + source.buffer = buffer; + source.connect(audioContext.destination); + source.start(); + return source; +} + +/////////////////////////////////////////////////////////////////////////////// +// ZzFX Music Renderer v2.0.3 by Keith Clark and Nishant kaushal + +/////////////////////////////////////////////////////////////////////////////// +// ZzFX Music Renderer v2.0.3 by Keith Clark and Nishant kaushal + +function zzfxM(instruments, patterns, sequence, BPM = 125) +{ + if (!soundEnable) return; + let instrumentParameters; + let i; + let j; + let k; + let note; + let sample; + let patternChannel; + let notFirstBeat; + let stop; + let instrument; + let pitch; + let attenuation; + let outSampleOffset; + let isSequenceEnd; + let sampleOffset = 0; + let nextSampleOffset; + let sampleBuffer = []; + let leftChannelBuffer = []; + let rightChannelBuffer = []; + let channelIndex = 0; + let panning = 0; + let hasMore = 1; + let sampleCache = {}; + let beatLength = zzfxR / BPM * 60 >> 2; + + // for each channel in order until there are no more + for(; hasMore; channelIndex++) { + + // reset current values + sampleBuffer = [hasMore = notFirstBeat = pitch = outSampleOffset = 0]; + + // for each pattern in sequence + sequence.map((patternIndex, sequenceIndex) => { + // get pattern for current channel, use empty 1 note pattern if none found + patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0]; + + // check if there are more channels + hasMore |= !!patterns[patternIndex][channelIndex]; + + // get next offset, use the length of first channel + nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - !notFirstBeat) * beatLength; + // for each beat in pattern, plus one extra if end of sequence + isSequenceEnd = sequenceIndex == sequence.length - 1; + for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) { + + // + note = patternChannel[i]; + + // stop if end, different instrument or new note + stop = i == patternChannel.length + isSequenceEnd - 1 && isSequenceEnd || + instrument != (patternChannel[0] || 0) | note | 0; + + // fill buffer with samples for previous beat, most cpu intensive part + for (j = 0; j < beatLength && notFirstBeat; + + // fade off attenuation at end of beat if stopping note, prevents clicking + j++ > beatLength - 99 && stop ? attenuation += (attenuation < 1) / 99 : 0 + ) { + // copy sample to stereo buffers with panning + sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0; + leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample; + rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample; + } + + // set up for next note + if (note) { + // set attenuation + attenuation = note % 1; + panning = patternChannel[1] || 0; + if (note |= 0) { + // get cached sample + sampleBuffer = sampleCache[ + [ + instrument = patternChannel[sampleOffset = 0] || 0, + note + ] + ] = sampleCache[[instrument, note]] || ( + // add sample to cache + instrumentParameters = [...instruments[instrument]], + instrumentParameters[2] *= 2 ** ((note - 12) / 12), + + // allow negative values to stop notes + note > 0 ? zzfxG(...instrumentParameters) : [] + ); + } + } + } + + // update the sample offset + outSampleOffset = nextSampleOffset; + }); + } + + return [leftChannelBuffer, rightChannelBuffer]; +} \ No newline at end of file diff --git a/Games/Space_Dominators/engine/engineDebug.js b/Games/Space_Dominators/engine/engineDebug.js new file mode 100644 index 0000000000..86cb127df9 --- /dev/null +++ b/Games/Space_Dominators/engine/engineDebug.js @@ -0,0 +1,453 @@ +/* + LittleJS Debug System + + Debug Features + - debug console + - debug rendering + - debug controls + - save snapshot +*/ + +'use strict'; + +const debug = 1; +const enableAsserts = 1; +const debugPointSize = .5; + +let showWatermark = 1; +let godMode = 0; +let debugRects = []; +let debugOverlay = 0; +let debugPhysics = 0; +let debugParticles = 0; +let debugCanvas = -1; +let debugTakeScreenshot; +let downloadLink; + +// debug helper functions +const ASSERT = enableAsserts ? (...assert)=> console.assert(...assert) : ()=>{}; +const debugRect = (pos, size=0, color='#fff', time=0, angle=0, fill=0)=> +{ + ASSERT(typeof color == 'string'); // pass in regular html strings as colors + debugRects.push({pos, size, color, time:new Timer(time), angle, fill}); +} +const debugCircle = (pos, radius, color, time, fill=0)=> debugRect(pos, radius, color, time, fill); +const debugPoint = (pos, color, time, angle)=> debugRect(pos, 0, color, time, angle); +const debugLine = (posA, posB, color, thickness=.1, time)=> +{ + const halfDelta = vec2((posB.x - posA.x)*.5, (posB.y - posA.y)*.5); + const size = vec2(thickness, halfDelta.length()*2); + debugRect(posA.add(halfDelta), size, color, time, halfDelta.angle(), 1); +} + +const debugSaveCanvas = (canvas, filename = engineName + '.png') => +{ + downloadLink.download = "screenshot.png"; + downloadLink.href = canvas.toDataURL('image/png').replace('image/png','image/octet-stream'); + downloadLink.click(); +} +const debugAABB = (pA, pB, sA, sB, color)=> +{ + const minPos = vec2(min(pA.x - sA.x/2, pB.x - sB.x/2), min(pA.y - sA.y/2, pB.y - sB.y/2)); + const maxPos = vec2(max(pA.x + sA.x/2, pB.x + sB.x/2), max(pA.y + sA.y/2, pB.y + sB.y/2)); + debugRect(minPos.lerp(maxPos,.5), maxPos.subtract(minPos), color); +} + +/////////////////////////////////////////////////////////////////////////////// +// engine debug function (called automatically) + +const debugInit = ()=> +{ + // create link for saving screenshots + document.body.appendChild(downloadLink = document.createElement('a')); + downloadLink.style.display = 'none'; +} + +const debugUpdate = ()=> +{ + if (!debug) + return; + + if (keyWasPressed(192)) // ~ + { + debugOverlay = !debugOverlay; + } + if (keyWasPressed(49)) // 1 + { + debugPhysics = !debugPhysics; + debugParticles = 0; + } + if (keyWasPressed(50)) // 2 + { + debugParticles = !debugParticles; + debugPhysics = 0; + } + if (keyWasPressed(51)) // 3 + { + godMode = !godMode; + } + + if (keyWasPressed(53)) // 5 + { + debugTakeScreenshot = 1; + } + if (keyWasPressed(54)) // 6 + { + //debugToggleParticleEditor(); + //debugPhysics = debugParticles = 0; + } + if (keyWasPressed(55)) // 7 + { + } + if (keyWasPressed(56)) // 8 + { + } + if (keyWasPressed(57)) // 9 + { + } + if (keyWasPressed(48)) // 0 + { + showWatermark = !showWatermark; + } + + // asserts to check for things that could go wrong + ASSERT(gravity <= 0) // only supports downward gravity +} + +const debugRender = ()=> +{ + if (debugTakeScreenshot) + { + debugSaveCanvas(mainCanvas); + debugTakeScreenshot = 0; + } + + if (debugOverlay) + { + for(const o of engineObjects) + { + if (o.canvas) + continue; // skip tile layers + + const size = o.size.copy(); + size.x = max(size.x, .2); + size.y = max(size.y, .2); + + const color = new Color( + o.collideTiles?1:0, + o.collideSolidObjects?1:0, + o.isSolid?1:0, + o.parent ? .2 : .5); + + // show object info + drawRect(o.pos, size, color); + drawRect(o.pos, size.scale(.8), o.parent ? new Color(1,1,1,.5) : new Color(0,0,0,.8)); + o.parent && drawLine(o.pos, o.parent.pos, .1, new Color(0,0,1,.5)); + } + + // mouse pick + let bestDistance = Infinity, bestObject; + for(const o of engineObjects) + { + const distance = mousePosWorld.distanceSquared(o.pos); + if (distance < bestDistance) + { + bestDistance = distance; + bestObject = o + } + } + + if (bestObject) + { + const raycastHitPos = tileCollisionRaycast(bestObject.pos, mousePosWorld); + raycastHitPos && drawRect(raycastHitPos.int().add(vec2(.5)), vec2(1), new Color(0,1,1,.3)); + drawRect(mousePosWorld.int().add(vec2(.5)), vec2(1), new Color(0,0,1,.5)); + drawLine(mousePosWorld, bestObject.pos, .1, !raycastHitPos ? new Color(0,1,0,.5) : new Color(1,0,0,.5)); + + let pos = mousePosWorld.copy(), height = vec2(0,.5); + const printVec2 = (v)=> '(' + (v.x>0?' ':'') + (v.x).toFixed(2) + ',' + (v.y>0?' ':'') + (v.y).toFixed(2) + ')'; + const args = [.5, new Color, .05, undefined, undefined, 'monospace']; + + drawText('pos = ' + printVec2(bestObject.pos) + + (bestObject.angle>0?' ':' ') + (bestObject.angle*180/PI).toFixed(1) + '°', + pos = pos.add(height), ...args); + drawText('vel = ' + printVec2(bestObject.velocity), pos = pos.add(height), ...args); + drawText('size = ' + printVec2(bestObject.size), pos = pos.add(height), ...args); + drawText('collision = ' + getTileCollisionData(mousePosWorld), pos = mousePosWorld.subtract(height), ...args); + } + + glCopyToContext(mainContext); + } + + { + // render debug rects + mainContext.lineWidth = 1; + const pointSize = debugPointSize * cameraScale; + debugRects.forEach(r=> + { + // create canvas transform from world space to screen space + const pos = worldToScreen(r.pos); + + mainContext.save(); + mainContext.lineWidth = 2; + mainContext.translate(pos.x|0, pos.y|0); + mainContext.rotate(r.angle); + mainContext.fillStyle = mainContext.strokeStyle = r.color; + + if (r.size == 0 || r.size.x === 0 && r.size.y === 0 ) + { + // point + mainContext.fillRect(-pointSize/2, -1, pointSize, 3), + mainContext.fillRect(-1, -pointSize/2, 3, pointSize); + } + else if (r.size.x != undefined) + { + // rect + const w = r.size.x*cameraScale|0, h = r.size.y*cameraScale|0; + r.fill && mainContext.fillRect(-w/2|0, -h/2|0, w, h), + mainContext.strokeRect(-w/2|0, -h/2|0, w, h); + } + else + { + // circle + mainContext.beginPath(); + mainContext.arc(0, 0, r.size*cameraScale, 0, 9); + r.fill && mainContext.fill(); + mainContext.stroke(); + } + + mainContext.restore(); + }); + + mainContext.fillStyle = mainContext.strokeStyle = '#fff'; + } + + { + let x = 9, y = -20, h = 30; + mainContext.fillStyle = '#fff'; + mainContext.textAlign = 'left'; + mainContext.textBaseline = 'top'; + mainContext.font = '28px monospace'; + mainContext.shadowColor = '#000'; + mainContext.shadowBlur = 9; + + if (debugOverlay) + { + mainContext.fillText(engineName, x, y += h); + mainContext.fillText('Objects: ' + engineObjects.length, x, y += h); + mainContext.fillText('Time: ' + formatTime(time), x, y += h); + mainContext.fillText('---------', x, y += h); + mainContext.fillStyle = '#f00'; + mainContext.fillText('~: Debug Overlay', x, y += h); + mainContext.fillStyle = debugPhysics ? '#f00' : '#fff'; + mainContext.fillText('1: Debug Physics', x, y += h); + mainContext.fillStyle = debugParticles ? '#f00' : '#fff'; + mainContext.fillText('2: Debug Particles', x, y += h); + mainContext.fillStyle = godMode ? '#f00' : '#fff'; + mainContext.fillText('3: God Mode', x, y += h); + mainContext.fillStyle = '#fff'; + mainContext.fillText('5: Save Screenshot', x, y += h); + //mainContext.fillStyle = debugParticleEditor ? '#f00' : '#fff'; + //mainContext.fillText('6: Particle Editor', x, y += h); + } + else + { + mainContext.fillText(debugPhysics ? 'Debug Physics' : '', x, y += h); + mainContext.fillText(debugParticles ? 'Debug Particles' : '', x, y += h); + mainContext.fillText(godMode ? 'God Mode' : '', x, y += h); + } + + mainContext.shadowBlur = 0; + } + + debugRects = debugRects.filter(r=>!r.time.elapsed()); +} + +/////////////////////////////////////////////////////////////////////////////// +// particle system editor +let debugParticleEditor = 0, debugParticleSystem, debugParticleSystemDiv, particleSystemCode; + +const debugToggleParticleEditor = ()=> +{ + debugParticleEditor = !debugParticleEditor; + + if (debugParticleEditor) + { + if (!debugParticleSystem || debugParticleSystem.destroyed) + debugParticleSystem = new ParticleEmitter(cameraPos); + } + else if (debugParticleSystem && !debugParticleSystem.destroyed) + debugParticleSystem.destroy(); + + + const colorToHex = (color)=> + { + const componentToHex = (c)=> + { + const hex = (c*255|0).toString(16); + return hex.length == 1 ? "0" + hex : hex; + } + + return "#" + componentToHex(color.r) + componentToHex(color.g) + componentToHex(color.b); + } + const hexToColor = (hex)=> + { + return new Color( + parseInt(hex.substr(1,2), 16)/255, + parseInt(hex.substr(3,2), 16)/255, + parseInt(hex.substr(5,2), 16)/255) + } + + if (!debugParticleSystemDiv) + { + const div = debugParticleSystemDiv = document.createElement('div'); + div.innerHTML = 'Particle Editor'; + div.style = 'position:absolute;top:10;left:10;color:#fff'; + document.body.appendChild(div); + + for( const setting of debugParticleSettings) + { + const input = setting[2] = document.createElement('input'); + const name = setting[0]; + const type = setting[1]; + if (type) + { + if (type == 'color') + { + input.type = type; + const color = debugParticleSystem[name]; + input.value = colorToHex(color); + } + else if (type == 'alpha' && name == 'colorStartAlpha') + input.value = debugParticleSystem.colorStartA.a; + else if (type == 'alpha' && name == 'colorEndAlpha') + input.value = debugParticleSystem.colorEndA.a; + else if (name == 'tileSizeX') + input.value = debugParticleSystem.tileSize.x; + else if (name == 'tileSizeY') + input.value = debugParticleSystem.tileSize.y; + } + else + input.value = debugParticleSystem[name] || '0'; + + input.oninput = (e)=> + { + const inputFloat = parseFloat(input.value) || 0; + if (type) + { + if (type == 'color') + { + const color = hexToColor(input.value); + debugParticleSystem[name].r = color.r; + debugParticleSystem[name].g = color.g; + debugParticleSystem[name].b = color.b; + } + else if (type == 'alpha' && name == 'colorStartAlpha') + { + debugParticleSystem.colorStartA.a = clamp(inputFloat); + debugParticleSystem.colorStartB.a = clamp(inputFloat); + } + else if (type == 'alpha' && name == 'colorEndAlpha') + { + debugParticleSystem.colorEndA.a = clamp(inputFloat); + debugParticleSystem.colorEndB.a = clamp(inputFloat); + } + else if (name == 'tileSizeX') + { + debugParticleSystem.tileSize = vec2(parseInt(input.value), debugParticleSystem.tileSize.y); + } + else if (name == 'tileSizeY') + { + debugParticleSystem.tileSize.y = vec2(debugParticleSystem.tileSize.x, parseInt(input.value)); + } + } + else + debugParticleSystem[name] = inputFloat; + + updateCode(); + } + div.appendChild(document.createElement('br')); + div.appendChild(input); + div.appendChild(document.createTextNode(' ' + name)); + } + + div.appendChild(document.createElement('br')); + div.appendChild(document.createElement('br')); + div.appendChild(particleSystemCode = document.createElement('input')); + particleSystemCode.disabled = true; + div.appendChild(document.createTextNode(' code')); + + div.appendChild(document.createElement('br')); + const button = document.createElement('button') + div.appendChild(button); + button.innerHTML = 'Copy To Clipboard'; + + button.onclick = (e)=> navigator.clipboard.writeText(particleSystemCode.value); + + const updateCode = ()=> + { + let code = ''; + let count = 0; + for( const setting of debugParticleSettings) + { + const name = setting[0]; + const type = setting[1]; + let value; + if (name == 'tileSizeX' || type == 'alpha') + continue; + + if (count++) + code += ', '; + + if (name == 'tileSizeY') + { + value = `vec2(${debugParticleSystem.tileSize.x},${debugParticleSystem.tileSize.y})`; + } + else if (type == 'color') + { + const c = debugParticleSystem[name]; + value = `new Color(${c.r},${c.g},${c.b},${c.a})`; + } + else + value = debugParticleSystem[name]; + code += value; + } + + particleSystemCode.value = '...[' + code + ']'; + } + updateCode(); + } + debugParticleSystemDiv.style.display = debugParticleEditor ? '' : 'none' +} + +const debugParticleSettings = +[ + ['emitSize'], + ['emitTime'], + ['emitRate'], + ['emitConeAngle'], + ['tileIndex'], + ['tileSizeX', 'tileSize'], + ['tileSizeY', 'tileSize'], + ['colorStartA', 'color'], + ['colorStartB', 'color'], + ['colorStartAlpha', 'alpha'], + ['colorEndA', 'color'], + ['colorEndB', 'color'], + ['colorEndAlpha', 'alpha'], + ['particleTime'], + ['sizeStart'], + ['sizeEnd'], + ['speed'], + ['angleSpeed'], + ['damping'], + ['angleDamping'], + ['gravityScale'], + ['particleConeAngle'], + ['fadeRate'], + ['randomness'], + ['collideTiles'], + ['additive'], + ['randomColorComponents'], + ['renderOrder'], +]; \ No newline at end of file diff --git a/Games/Space_Dominators/engine/engineDraw.js b/Games/Space_Dominators/engine/engineDraw.js new file mode 100644 index 0000000000..7c348c004d --- /dev/null +++ b/Games/Space_Dominators/engine/engineDraw.js @@ -0,0 +1,131 @@ +/* + LittleJS Drawing System + + - Super fast tile sheet rendering + - Utility functions for webgl + - Adapted from Tiny-Canvas https://github.com/bitnenfer/tiny-canvas +*/ + +'use strict'; + +///////////////////////////////////////////////////////////////////////////////\ + +const screenToWorld = (screenPos)=> + screenPos.add(vec2(.5)).subtract(mainCanvasSize.scale(.5)).multiply(vec2(1/cameraScale,-1/cameraScale)).add(cameraPos); +const worldToScreen = (worldPos)=> + worldPos.subtract(cameraPos).multiply(vec2(cameraScale,-cameraScale)).add(mainCanvasSize.scale(.5)).subtract(vec2(.5)); + +// draw textured tile centered on pos +function drawTile(pos, size=vec2(1), tileIndex=-1, tileSize=defaultTileSize, color=new Color, angle=0, mirror, + additiveColor=new Color(0,0,0,0)) +{ + if (!size.x | !size.y) + return; + + showWatermark && ++drawCount; + if (glEnable) + { + if (tileIndex < 0) + { + // if negative tile index, force untextured + glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0, 0, 0, color.rgbaInt()); + } + else + { + // calculate uvs and render + const cols = tileImage.width / tileSize.x |0; + const uvSizeX = tileSize.x * tileImageSizeInverse.x; + const uvSizeY = tileSize.y * tileImageSizeInverse.y; + const uvX = (tileIndex%cols)*uvSizeX, uvY = (tileIndex/cols|0)*uvSizeY; + glDraw(pos.x, pos.y, size.x, size.y, angle, mirror, + uvX, uvY, uvX + uvSizeX, uvY + uvSizeY, color.rgbaInt(), additiveColor.rgbaInt()); + } + } + else + { + // normal canvas 2D rendering method (slower) + drawCanvas2D(pos, size, angle, mirror, (context)=> + { + if (tileIndex < 0) + { + // if negative tile index, force untextured + context.fillStyle = color.rgba(); + context.fillRect(-.5, -.5, 1, 1); + } + else + { + // calculate uvs and render + const cols = tileImage.width / tileSize.x |0; + const sX = (tileIndex%cols)*tileSize.x + tileBleedShrinkFix; + const sY = (tileIndex/cols|0)*tileSize.y + tileBleedShrinkFix; + const sWidth = tileSize.x - 2*tileBleedShrinkFix; + const sHeight = tileSize.y - 2*tileBleedShrinkFix; + context.globalAlpha = color.a; // only alpha is supported + context.drawImage(tileImage, sX, sY, sWidth, sHeight, -.5, -.5, 1, 1); + } + }); + } +} + +// draw a colored untextured rect centered on pos +function drawRect(pos, size, color, angle) +{ + drawTile(pos, size, -1, defaultTileSize, color, angle); +} + +// draw textured tile centered on pos in screen space +function drawTileScreenSpace(pos, size=vec2(1), tileIndex, tileSize, color, angle, mirror, additiveColor) +{ + drawTile(screenToWorld(pos), size.scale(1/cameraScale), tileIndex, tileSize, color, angle, mirror, additiveColor); +} + +// draw a colored untextured rect in screen space +function drawRectScreenSpace(pos, size, color, angle) +{ + drawTileScreenSpace(pos, size, -1, defaultTileSize, color, angle); +} + +// draw a colored line between two points +function drawLine(posA, posB, thickness=.1, color) +{ + const halfDelta = vec2((posB.x - posA.x)*.5, (posB.y - posA.y)*.5); + const size = vec2(thickness, halfDelta.length()*2); + drawRect(posA.add(halfDelta), size, color, halfDelta.angle()); +} + +// draw directly to the 2d canvas in world space (bipass webgl) +function drawCanvas2D(pos, size, angle, mirror, drawFunction) +{ + // create canvas transform from world space to screen space + pos = worldToScreen(pos); + size = size.scale(cameraScale); + mainContext.save(); + mainContext.translate(pos.x+.5|0, pos.y-.5|0); + mainContext.rotate(angle); + mainContext.scale(mirror?-size.x:size.x, size.y); + drawFunction(mainContext); + mainContext.restore(); +} + +// draw text in world space without canvas scaling because that messes up fonts +function drawText(text, pos, size=1, color=new Color, lineWidth=0, lineColor=new Color(0,0,0), textAlign='center', font=defaultFont) +{ + pos = worldToScreen(pos); + mainContext.font = size*cameraScale + 'px '+ font; + mainContext.textAlign = textAlign; + mainContext.textBaseline = 'middle'; + if (lineWidth) + { + mainContext.lineWidth = lineWidth*cameraScale; + mainContext.strokeStyle = lineColor.rgba(); + mainContext.strokeText(text, pos.x, pos.y); + } + mainContext.fillStyle = color.rgba(); + mainContext.fillText(text, pos.x, pos.y); +} + +// enable additive or regular blend mode +function setBlendMode(additive) +{ + glEnable ? glSetBlendMode(additive) : mainContext.globalCompositeOperation = additive ? 'lighter' : 'source-over'; +} \ No newline at end of file diff --git a/Games/Space_Dominators/engine/engineInput.js b/Games/Space_Dominators/engine/engineInput.js new file mode 100644 index 0000000000..947f781a93 --- /dev/null +++ b/Games/Space_Dominators/engine/engineInput.js @@ -0,0 +1,152 @@ +/* + LittleJS Input System + - Tracks key down, pressed, and released + - Also tracks mouse buttons, position, and wheel + - Supports multiple gamepads +*/ + +'use strict'; + +/////////////////////////////////////////////////////////////////////////////// +// input + +const enableGamepads = 1; +const enableTouchInput = 0; +const copyGamepadDirectionToStick = 1; +const copyWASDToDpad = 1; + +// input for all devices including keyboard, mouse, and gamepad. (d=down, p=pressed, r=released) +const inputData = [[]]; +const keyIsDown = (key, device=0)=> inputData[device][key] && inputData[device][key].d ? 1 : 0; +const keyWasPressed = (key, device=0)=> inputData[device][key] && inputData[device][key].p ? 1 : 0; +const keyWasReleased = (key, device=0)=> inputData[device][key] && inputData[device][key].r ? 1 : 0; +const clearInput = ()=> inputData[0].length = 0; + +// mouse input is stored with keyboard +let hadInput = 0; +let mouseWheel = 0; +let mousePosScreen = vec2(); +let mousePosWorld = vec2(); +const mouseIsDown = keyIsDown; +const mouseWasPressed = keyWasPressed; +const mouseWasReleased = keyWasReleased; + +// handle input events +onkeydown = e=> +{ + if (debug && e.target != document.body) return; + e.repeat || (inputData[isUsingGamepad = 0][remapKeyCode(e.keyCode)] = {d:hadInput=1, p:1}); +} +onkeyup = e=> +{ + if (debug && e.target != document.body) return; + const c = remapKeyCode(e.keyCode); inputData[0][c] && (inputData[0][c].d = 0, inputData[0][c].r = 1); +} +onmousedown = e=> (inputData[0][e.button] = {d:hadInput=1, p:1}, onmousemove(e)); +onmouseup = e=> inputData[0][e.button] && (inputData[0][e.button].d = 0, inputData[0][e.button].r = 1); +onmousemove = e=> +{ + if (!mainCanvas) + return; + + // convert mouse pos to canvas space + const rect = mainCanvas.getBoundingClientRect(); + mousePosScreen.x = mainCanvasSize.x * percent(e.x, rect.right, rect.left); + mousePosScreen.y = mainCanvasSize.y * percent(e.y, rect.bottom, rect.top); +} +if(debug) + onwheel = e=> e.ctrlKey || (mouseWheel = sign(e.deltaY)); +oncontextmenu = e=> !1; // prevent right click menu +const remapKeyCode = c=> copyWASDToDpad ? c==87?38 : c==83?40 : c==65?37 : c==68?39 : c : c; + +//////////////////////////////////////////////////////////////////// +// gamepad + +let isUsingGamepad = 0; +let gamepadCount = 0; +const gamepadStick = (stick, gamepad=0)=> gamepad < gamepadCount ? inputData[gamepad+1].stickData[stick] : vec2(); +const gamepadIsDown = (button, gamepad=0)=> gamepad < gamepadCount ? keyIsDown (button, gamepad+1) : 0; +const gamepadWasPressed = (button, gamepad=0)=> gamepad < gamepadCount ? keyWasPressed (button, gamepad+1) : 0; +const gamepadWasReleased = (button, gamepad=0)=> gamepad < gamepadCount ? keyWasReleased(button, gamepad+1) : 0; + +function updateGamepads() +{ + if (!navigator.getGamepads || !enableGamepads) + return; + + if (!document.hasFocus() && !debug) + return; + + const gamepads = navigator.getGamepads(); + gamepadCount = 0; + for(let i = 0; i < navigator.getGamepads().length; ++i) + { + // get or create gamepad data + const gamepad = gamepads[i]; + let data = inputData[i+1]; + if (!data) + { + data = inputData[i+1] = []; + data.stickData = [vec2(), vec2()]; + } + + if (gamepad && gamepad.axes.length >= 2) + { + gamepadCount = i+1; + + // read analog sticks and clamp dead zone + const deadZone = .3, deadZoneMax = .8; + const applyDeadZone = (v)=> + v > deadZone ? percent( v, deadZoneMax, deadZone) : + v < -deadZone ? -percent(-v, deadZoneMax, deadZone) : 0; + data.stickData[0] = vec2(applyDeadZone(gamepad.axes[0]), applyDeadZone(-gamepad.axes[1])); + + if (copyGamepadDirectionToStick) + { + // copy dpad to left analog stick when pressed + if (gamepadIsDown(12,i)|gamepadIsDown(13,i)|gamepadIsDown(14,i)|gamepadIsDown(15,i)) + data.stickData[0] = vec2(gamepadIsDown(15,i) - gamepadIsDown(14,i), gamepadIsDown(12,i) - gamepadIsDown(13,i)); + } + + // clamp stick input to unit vector + data.stickData[0] = data.stickData[0].clampLength(); + + // read buttons + gamepad.buttons.map((button, j)=> + { + inputData[i+1][j] = button.pressed ? {d:1, p:!gamepadIsDown(j,i)} : + inputData[i+1][j] = {r:gamepadIsDown(j,i)} + isUsingGamepad |= button.pressed && !i; + }); + } + } +} + +/////////////////////////////////////////////////////////////////////////////// +// touch screen input + +if (enableTouchInput && window.ontouchstart !== undefined) +{ + // handle all touch events the same way + ontouchstart = ontouchmove = ontouchend = e=> + { + e.button = 0; // all touches are left click + hadInput || zzfx(hadInput = 1) ; // fix mobile audio, force it to play a sound the first time + + // check if touching and pass to mouse events + const touching = e.touches.length; + if (touching) + { + // set event pos and pass it along + e.x = e.touches[0].clientX; + e.y = e.touches[0].clientY; + wasTouching ? onmousemove(e) : onmousedown(e); + } + else if (wasTouching) + wasTouching && onmouseup(e); + + // set was touching + wasTouching = touching; + } + let wasTouching; +} \ No newline at end of file diff --git a/Games/Space_Dominators/engine/engineObject.js b/Games/Space_Dominators/engine/engineObject.js new file mode 100644 index 0000000000..a56b308558 --- /dev/null +++ b/Games/Space_Dominators/engine/engineObject.js @@ -0,0 +1,268 @@ +/* + LittleJS Object Base Class + - Base object class used by the engine + - Automatically adds self to object list + - Will be updated and rendered each frame + - Renders as a sprite from a tilesheet by default + - Can have color and addtive color applied + - 2d Physics and collision system + - Sorted by renderOrder + - Objects can have children attached + - Parents are updated before children, and set child transform + - Call destroy() to get rid of objects +*/ + +'use strict'; + +/////////////////////////////////////////////////////////////////////////////// + +// object defaults +const defaultObjectSize = vec2(.999); +const defaultObjectMass = 1; +const defaultObjectDamping = .99; +const defaultObjectAngleDamping = .99; +const defaultObjectElasticity = 0; +const defaultObjectFriction = .8; +const maxObjectSpeed = 1; + +class EngineObject +{ + constructor(pos, size=defaultObjectSize, tileIndex=-1, tileSize=defaultTileSize, angle=0, color) + { + // set passed in params + ASSERT(pos); + this.pos = pos.copy(); + this.size = size; + this.tileIndex = tileIndex; + this.tileSize = tileSize; + this.angle = angle; + this.color = color; + + // set physics defaults + this.mass = defaultObjectMass; + this.damping = defaultObjectDamping; + this.angleDamping = defaultObjectAngleDamping; + this.elasticity = defaultObjectElasticity; + this.friction = defaultObjectFriction; + + // init other object stuff + this.spawnTime = time; + this.velocity = vec2(this.collideSolidObjects = this.renderOrder = this.angleVelocity = 0); + this.collideTiles = this.gravityScale = 1; + this.children = []; + + // add to list of objects + engineObjects.push(this); + } + + update() + { + if (this.parent) + { + // copy parent pos/angle + this.pos = this.localPos.multiply(vec2(this.getMirrorSign(),1)).rotate(-this.parent.angle).add(this.parent.pos); + this.angle = this.getMirrorSign()*this.localAngle + this.parent.angle; + return; + } + + // limit max speed to prevent missing collisions + this.velocity.x = clamp(this.velocity.x, maxObjectSpeed, -maxObjectSpeed); + this.velocity.y = clamp(this.velocity.y, maxObjectSpeed, -maxObjectSpeed); + + // apply physics + const oldPos = this.pos.copy(); + this.pos.x += this.velocity.x = this.damping * this.velocity.x; + this.pos.y += this.velocity.y = this.damping * this.velocity.y + gravity * this.gravityScale; + this.angle += this.angleVelocity *= this.angleDamping; + + // physics sanity checks + ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1); + ASSERT(this.damping >= 0 && this.damping <= 1); + + if (!this.mass) // do not update collision for fixed objects + return; + + const wasMovingDown = this.velocity.y < 0; + if (this.groundObject) + { + // apply friction in local space of ground object + const groundSpeed = this.groundObject.velocity ? this.groundObject.velocity.x : 0; + this.velocity.x = groundSpeed + (this.velocity.x - groundSpeed) * this.friction; + this.groundObject = 0; + //debugPhysics && debugPoint(this.pos.subtract(vec2(0,this.size.y/2)), '#0f0'); + } + + if (this.collideSolidObjects) + { + // check collisions against solid objects + const epsilon = 1e-3; // necessary to push slightly outside of the collision + for(const o of engineCollideObjects) + { + // non solid objects don't collide with eachother + if (!this.isSolid & !o.isSolid || o.destroyed || o.parent) + continue; + + // check collision + if (!isOverlapping(this.pos, this.size, o.pos, o.size) || o == this) + continue; + + // pass collision to objects + if (!this.collideWithObject(o) | !o.collideWithObject(this)) + continue; + + if (isOverlapping(oldPos, this.size, o.pos, o.size)) + { + // if already was touching, try to push away + const deltaPos = oldPos.subtract(o.pos); + const length = deltaPos.length(); + const pushAwayAccel = .001; // push away if alread overlapping + const velocity = length < .01 ? randVector(pushAwayAccel) : deltaPos.scale(pushAwayAccel/length); + this.velocity = this.velocity.add(velocity); + if (o.mass) // push away if not fixed + o.velocity = o.velocity.subtract(velocity); + + debugPhysics && debugAABB(this.pos, o.pos, this.size, o.size, '#f00'); + continue; + } + + // check for collision + const sx = this.size.x + o.size.x; + const sy = this.size.y + o.size.y; + const smallStepUp = (oldPos.y - o.pos.y)*2 > sy + gravity; // prefer to push up if small delta + const isBlockedX = abs(oldPos.y - o.pos.y)*2 < sy; + const isBlockedY = abs(oldPos.x - o.pos.x)*2 < sx; + + if (smallStepUp || isBlockedY || !isBlockedX) // resolve y collision + { + // push outside object collision + this.pos.y = o.pos.y + (sy*.5 + epsilon) * sign(oldPos.y - o.pos.y); + if (o.groundObject && wasMovingDown || !o.mass) + { + // set ground object if landed on something + if (wasMovingDown) + this.groundObject = o; + + // bounce if other object is fixed or grounded + this.velocity.y *= -this.elasticity; + } + else if (o.mass) + { + // set center of mass velocity + this.velocity.y = o.velocity.y = + (this.mass * this.velocity.y + o.mass * o.velocity.y) / (this.mass + o.mass); + } + debugPhysics && smallStepUp && (abs(oldPos.x - o.pos.x)*2 > sx) && console.log('stepUp', oldPos.y - o.pos.y); + } + if (!smallStepUp && (isBlockedX || !isBlockedY)) // resolve x collision + { + // push outside collision + this.pos.x = o.pos.x + (sx*.5 + epsilon) * sign(oldPos.x - o.pos.x); + if (o.mass) + { + // set center of mass velocity + this.velocity.x = o.velocity.x = + (this.mass * this.velocity.x + o.mass * o.velocity.x) / (this.mass + o.mass); + } + else // bounce if other object is fixed + this.velocity.x *= -this.elasticity; + } + + debugPhysics && debugAABB(this.pos, o.pos, this.size, o.size, '#f0f'); + } + } + if (this.collideTiles) + { + // check collision against tiles + if (tileCollisionTest(this.pos, this.size, this)) + { + //debugPhysics && debugRect(this.pos, this.size, '#ff0'); + + // if already was stuck in collision, don't do anything + // this should not happen unless something starts in collision + if (!tileCollisionTest(oldPos, this.size, this)) + { + // test which side we bounced off (or both if a corner) + const isBlockedY = tileCollisionTest(new Vector2(oldPos.x, this.pos.y), this.size, this); + const isBlockedX = tileCollisionTest(new Vector2(this.pos.x, oldPos.y), this.size, this); + if (isBlockedY || !isBlockedX) + { + // set if landed on ground + this.groundObject = wasMovingDown; + + // push out of collision and bounce + this.pos.y = oldPos.y; + this.velocity.y *= -this.elasticity; + } + if (isBlockedX || !isBlockedY) + { + // push out of collision and bounce + this.pos.x = oldPos.x; + this.velocity.x *= -this.elasticity; + } + } + } + } + } + + render() + { + // default object render + drawTile(this.pos, this.size, this.tileIndex, this.tileSize, this.color, this.angle, this.mirror, this.additiveColor); + } + + destroy() + { + if (this.destroyed) + return; + + // disconnect from parent and destroy chidren + this.destroyed = 1; + this.parent && this.parent.removeChild(this); + for(const child of this.children) + child.destroy(child.parent = 0); + } + collideWithTile(data, pos) { return data > 0; } + collideWithTileRaycast(data, pos) { return data > 0; } + collideWithObject(o) { return 1; } + getAliveTime() { return time - this.spawnTime; } + applyAcceleration(a) { ASSERT(!this.isFixed()); this.velocity = this.velocity.add(a); } + applyForce(force) { this.applyAcceleration(force.scale(1/this.mass)); } + isFixed() { return !this.mass; } + getMirrorSign(s=1) { return this.mirror ? -s : s; } + + addChild(child, localPos=vec2(), localAngle=0) + { + ASSERT(!child.parent && !this.children.includes(child)); + this.children.push(child); + child.parent = this; + child.localPos = localPos.copy(); + child.localAngle = localAngle; + } + removeChild(child) + { + ASSERT(child.parent == this && this.children.includes(child)); + this.children.splice(this.children.indexOf(child), 1); + child.parent = 0; + } + + setCollision(collideSolidObjects=1, isSolid, collideTiles=1) + { + ASSERT(collideSolidObjects || !isSolid); // solid objects must be set to collide + + // track collidable objects in separate list + if (collideSolidObjects && !this.collideSolidObjects) + { + ASSERT(!engineCollideObjects.includes(this)); + engineCollideObjects.push(this); + } + else if (!collideSolidObjects && this.collideSolidObjects) + { + ASSERT(engineCollideObjects.includes(this)) + engineCollideObjects.splice(engineCollideObjects.indexOf(this), 1); + } + + this.collideSolidObjects = collideSolidObjects; + this.isSolid = isSolid; + this.collideTiles = collideTiles; + } +} \ No newline at end of file diff --git a/Games/Space_Dominators/engine/engineParticle.js b/Games/Space_Dominators/engine/engineParticle.js new file mode 100644 index 0000000000..c4e80b1b93 --- /dev/null +++ b/Games/Space_Dominators/engine/engineParticle.js @@ -0,0 +1,200 @@ +/* + LittleJS Particle System + - Spawns particles with randomness from parameters + - Updates particle physics + - Fast particle rendering +*/ + +'use strict'; + +class ParticleEmitter extends EngineObject +{ + constructor + ( + pos, // world space position of emitter + emitSize = 0, // size of emitter (float for circle diameter, vec2 for rect) + emitTime = 0, // how long to stay alive (0 is forever) + emitRate = 100, // how many particles per second to spawn + emitConeAngle = PI, // local angle to apply velocity to particles from emitter + tileIndex = -1, // index into tile sheet, if <0 no texture is applied + tileSize = defaultTileSize, // tile size for particles + colorStartA = new Color, // color at start of life + colorStartB = new Color, // randomized between start colors + colorEndA = new Color(1,1,1,0), // color at end of life + colorEndB = new Color(1,1,1,0), // randomized between end colors + particleTime = .5, // how long particles live + sizeStart = .1, // how big are particles at start + sizeEnd = 1, // how big are particles at end + speed = .1, // how fast are particles when spawned + angleSpeed = .05, // how fast are particles rotating + damping = 1, // how much to dampen particle speed + angleDamping = 1, // how much to dampen particle angular speed + gravityScale = 0, // how much does gravity effect particles + particleConeAngle = PI, // cone for start particle angle + fadeRate = .1, // how quick to fade in particles at start/end in percent of life + randomness = .2, // apply extra randomness percent + collideTiles, // do particles collide against tiles + additive, // should particles use addtive blend + randomColorLinear = 1, // should color be randomized linearly or across each component + renderOrder = additive ? 1e9 : 0// render order for particles (additive is above other stuff by default) + ) + { + super(pos, new Vector2, tileIndex, tileSize); + + // emitter settings + this.emitSize = emitSize + this.emitTime = emitTime; + this.emitRate = emitRate; + this.emitConeAngle = emitConeAngle; + + // color settings + this.colorStartA = colorStartA; + this.colorStartB = colorStartB; + this.colorEndA = colorEndA; + this.colorEndB = colorEndB; + this.randomColorLinear = randomColorLinear; + + // particle settings + this.particleTime = particleTime; + this.sizeStart = sizeStart; + this.sizeEnd = sizeEnd; + this.speed = speed; + this.angleSpeed = angleSpeed; + this.damping = damping; + this.angleDamping = angleDamping; + this.gravityScale = gravityScale; + this.particleConeAngle = particleConeAngle; + this.fadeRate = fadeRate; + this.randomness = randomness; + this.collideTiles = collideTiles; + this.additive = additive; + this.renderOrder = renderOrder; + this.trailScale = + this.emitTimeBuffer = 0; + } + + update() + { + // only do default update to apply parent transforms + this.parent && super.update(); + + // update emitter + if (!this.emitTime || this.getAliveTime() <= this.emitTime) + { + // emit particles + if (this.emitRate) + { + const rate = 1/this.emitRate; + for(this.emitTimeBuffer += timeDelta; this.emitTimeBuffer > 0; this.emitTimeBuffer -= rate) + this.emitParticle(); + } + } + else + this.destroy(); + + debugParticles && debugRect(this.pos, vec2(this.emitSize), '#0f0', 0, this.angle); + } + + emitParticle() + { + // spawn a particle + const pos = this.emitSize.x != undefined ? // check if vec2 was used for size + (new Vector2(rand(-.5,.5), rand(-.5,.5))).multiply(this.emitSize).rotate(this.angle) // box emitter + : randInCircle(this.emitSize * .5); // circle emitter + const particle = new Particle(this.pos.add(pos), this.tileIndex, this.tileSize, + this.angle + rand(this.particleConeAngle, -this.particleConeAngle)); + + // randomness scales each paremeter by a percentage + const randomness = this.randomness; + const randomizeScale = (v)=> v + v*rand(randomness, -randomness); + + // randomize particle settings + const particleTime = randomizeScale(this.particleTime); + const sizeStart = randomizeScale(this.sizeStart); + const sizeEnd = randomizeScale(this.sizeEnd); + const speed = randomizeScale(this.speed); + const angleSpeed = randomizeScale(this.angleSpeed) * randSign(); + const coneAngle = rand(this.emitConeAngle, -this.emitConeAngle); + const colorStart = randColor(this.colorStartA, this.colorStartB, this.randomColorLinear); + const colorEnd = randColor(this.colorEndA, this.colorEndB, this.randomColorLinear); + + // build particle settings + particle.colorStart = colorStart; + particle.colorEndDelta = colorEnd.subtract(colorStart); + particle.velocity = (new Vector2).setAngle(this.angle + coneAngle, speed); + particle.angleVelocity = angleSpeed; + particle.lifeTime = particleTime; + particle.sizeStart = sizeStart; + particle.sizeEndDelta = sizeEnd - sizeStart; + //particle.mirror = rand(2)|0; // random mirroring + particle.fadeRate = this.fadeRate; + particle.damping = this.damping; + particle.angleDamping = this.angleDamping; + particle.elasticity = this.elasticity; + particle.friction = this.friction; + particle.gravityScale = this.gravityScale; + particle.collideTiles = this.collideTiles; + particle.additive = this.additive; + particle.renderOrder = this.renderOrder; + particle.trailScale = this.trailScale; + + // setup callbacks for particles + particle.destroyCallback = this.particleDestroyCallback; + this.particleCreateCallback && this.particleCreateCallback(particle); + + // return the newly created particle + return particle; + } + + render() {} // emitters are not rendered +} + +/////////////////////////////////////////////////////////////////////////////// +// particle object + +class Particle extends EngineObject +{ + constructor(pos, tileIndex, tileSize, angle) { super(pos, new Vector2, tileIndex, tileSize, angle); } + + render() + { + // modulate size and color + const p = min((time - this.spawnTime) / this.lifeTime, 1); + const radius = this.sizeStart + p * this.sizeEndDelta; + const size = new Vector2(radius, radius); + + const fadeRate = this.fadeRate*.5; + const color = new Color( + this.colorStart.r + p * this.colorEndDelta.r, + this.colorStart.g + p * this.colorEndDelta.g, + this.colorStart.b + p * this.colorEndDelta.b, + (this.colorStart.a + p * this.colorEndDelta.a) * + (p < fadeRate ? p/fadeRate : p > 1-fadeRate ? (1-p)/fadeRate : 1)); // fade alpha + + // draw the particle + this.additive && setBlendMode(1); + if (this.trailScale) + { + // trail style particles + const speed = this.velocity.length(); + const direction = this.velocity.scale(1/speed); + const trailLength = speed * this.trailScale; + size.y = max(size.x, trailLength); + this.angle = direction.angle(); + drawTile(this.pos.add(direction.multiply(vec2(0,-trailLength*.5))), size, this.tileIndex, this.tileSize, color, this.angle, this.mirror); + } + else + drawTile(this.pos, size, this.tileIndex, this.tileSize, color, this.angle, this.mirror); + this.additive && setBlendMode() + debugParticles && debugRect(this.pos, size, '#f005', 0, this.angle); + + if (p == 1) + { + this.color = color; + this.size = size; + this.destroyCallback && this.destroyCallback(this); + this.destroyed = 1; + return; + } + } +} \ No newline at end of file diff --git a/Games/Space_Dominators/engine/engineTileLayer.js b/Games/Space_Dominators/engine/engineTileLayer.js new file mode 100644 index 0000000000..13d654b437 --- /dev/null +++ b/Games/Space_Dominators/engine/engineTileLayer.js @@ -0,0 +1,255 @@ +/* + LittleJS Tile Layer System + - Caches arrays of tiles to offscreen canvas for fast rendering + - Unlimted numbers of layers, allocates canvases as needed + - Interfaces with EngineObject for collision + - Collision layer is separate from visible layers + - Tile layers can be drawn to using their context with canvas2d + - It is recommended to have a visible layer that matches the collision +*/ + +'use strict'; + +/////////////////////////////////////////////////////////////////////////////// +// Tile Collision + +let tileCollision = []; +let tileCollisionSize = vec2(); +const tileLayerCanvasCache = []; +const defaultTileLayerRenderOrder = -1e9; +const debugRaycast = 0; + +function initTileCollision(size) +{ + // reset collision to be clear + tileCollisionSize = size; + tileCollision = []; + for(let i=tileCollision.length = tileCollisionSize.area(); i--;) + tileCollision[i] = 0; +} + +const setTileCollisionData = (pos, data=0)=> + pos.arrayCheck(tileCollisionSize) && (tileCollision[(pos.y|0)*tileCollisionSize.x+pos.x|0] = data); +const getTileCollisionData = (pos)=> + pos.arrayCheck(tileCollisionSize) ? tileCollision[(pos.y|0)*tileCollisionSize.x+pos.x|0] : 0; + +function tileCollisionTest(pos, size=vec2(), object) +{ + // check if there is collision in a given area + const minX = pos.x - size.x*.5|0; + const minY = pos.y - size.y*.5|0; + const maxX = pos.x + size.x*.5|0; + const maxY = pos.y + size.y*.5|0; + for(let y = minY; y <= maxY; ++y) + for(let x = minX; x <= maxX; ++x) + { + const tileData = tileCollision[y*tileCollisionSize.x + x]; + if (tileData && (!object || object.collideWithTile(tileData, new Vector2(x, y)))) + return 1; + } +} + +// return the center of tile if any that is hit (this does not return the exact hit point) +// todo: return the exact hit point, it must still be inside the hit tile +function tileCollisionRaycast(posStart, posEnd, object) +{ + // test if a ray collides with tiles from start to end + posStart = posStart.int(); + posEnd = posEnd.int(); + const posDelta = posEnd.subtract(posStart); + const dx = abs(posDelta.x), dy = -abs(posDelta.y); + const sx = sign(posDelta.x), sy = sign(posDelta.y); + let e = dx + dy; + + for(let x = posStart.x, y = posStart.y;;) + { + const tileData = getTileCollisionData(vec2(x,y)); + if (tileData && (object ? object.collideWithTileRaycast(tileData, new Vector2(x, y)) : tileData > 0)) + { + debugRaycast && debugLine(posStart, posEnd, '#f00',.02, 1); + debugRaycast && debugPoint(new Vector2(x+.5, y+.5), '#ff0', 1); + return new Vector2(x+.5, y+.5); + } + + // update Bresenham line drawing algorithm + if (x == posEnd.x & y == posEnd.y) break; + const e2 = 2*e; + if (e2 >= dy) e += dy, x += sx; + if (e2 <= dx) e += dx, y += sy; + } + debugRaycast && debugLine(posStart, posEnd, '#00f',.02, 1); +} + +/////////////////////////////////////////////////////////////////////////////// +// Tile Layer Rendering System + +class TileLayerData +{ + constructor(tile=-1, direction=0, mirror=0, color=new Color) + { + this.tile = tile; + this.direction = direction; + this.mirror = mirror; + this.color = color; + } + clear() { this.tile = this.direction = this.mirror = 0; color = new Color; } +} + +class TileLayer extends EngineObject +{ + constructor(pos, size, scale=vec2(1), layer=0) + { + super(pos, size); + + // create new canvas if necessary + this.canvas = tileLayerCanvasCache.length ? tileLayerCanvasCache.pop() : document.createElement('canvas'); + this.context = this.canvas.getContext('2d'); + + this.scale = scale; + this.tileSize = defaultTileSize.copy(); + this.layer = layer; + this.renderOrder = defaultTileLayerRenderOrder + layer; + this.flushGLBeforeRender = 1; + + // init tile data + this.data = []; + for(let j = this.size.area(); j--;) + this.data.push(new TileLayerData()); + } + + destroy() + { + // add canvas back to the cache + tileLayerCanvasCache.push(this.canvas); + super.destroy(); + } + + setData(layerPos, data, redraw) + { + if (layerPos.arrayCheck(this.size)) + { + this.data[(layerPos.y|0)*this.size.x+layerPos.x|0] = data; + redraw && this.drawTileData(layerPos); + } + } + + getData(layerPos) + { return layerPos.arrayCheck(this.size) && this.data[(layerPos.y|0)*this.size.x+layerPos.x|0]; } + + update() {} // tile layers are not updated + render() + { + ASSERT(mainContext != this.context); // must call redrawEnd() after drawing tiles + + // flush and copy gl canvas because tile canvas does not use gl + this.flushGLBeforeRender && glEnable && glCopyToContext(mainContext); + + // draw the entire cached level onto the main canvas + const pos = worldToScreen(this.pos.add(vec2(0,this.size.y*this.scale.y))); + mainContext.drawImage + ( + this.canvas, pos.x, pos.y, + cameraScale*this.size.x*this.scale.x, cameraScale*this.size.y*this.scale.y + ); + } + + redraw() + { + // draw all the tile data to an offscreen canvas using webgl if possible + this.redrawStart(); + this.drawAllTileData(); + this.redrawEnd(); + } + + redrawStart(clear = 1) + { + // clear and set size + const width = this.size.x * this.tileSize.x; + const height = this.size.y * this.tileSize.y; + + if (clear) + { + this.canvas.width = width; + this.canvas.height = height; + } + + // save current render settings + this.savedRenderSettings = [mainCanvasSize, mainCanvas, mainContext, cameraScale, cameraPos]; + + // set camera transform for renering + cameraScale = this.tileSize.x; + cameraPos = this.size.scale(.5); + mainCanvas = this.canvas; + mainContext = this.context; + mainContext.imageSmoothingEnabled = !pixelated; // disable smoothing for pixel art + mainCanvasSize = vec2(width, height); + glPreRender(width, height); + } + + redrawEnd() + { + ASSERT(mainContext == this.context); // must call redrawStart() before drawing tiles + glCopyToContext(mainContext, 1); + //debugSaveCanvas(this.canvas); + + // set stuff back to normal + [mainCanvasSize, mainCanvas, mainContext, cameraScale, cameraPos] = this.savedRenderSettings; + } + + drawTileData(layerPos) + { + // first clear out where the tile was + const pos = layerPos.int().add(this.pos).add(vec2(.5)); + this.drawCanvas2D(pos, vec2(1), 0, 0, (context)=>context.clearRect(-.5, -.5, 1, 1)); + + // draw the tile + const d = this.getData(layerPos); + ASSERT(d.tile < 0 || mainContext == this.context); // must call redrawStart() before drawing tiles + d.tile < 0 || drawTile(pos, vec2(1), d.tile || -1, this.tileSize, d.color, d.direction*PI/2, d.mirror); + } + + drawAllTileData() + { + for(let x = this.size.x; x--;) + for(let y = this.size.y; y--;) + this.drawTileData(vec2(x,y)); + } + + // draw directly to the 2d canvas in world space (bipass webgl) + drawCanvas2D(pos, size, angle, mirror, drawFunction) + { + const context = this.context; + context.save(); + pos = pos.subtract(this.pos).multiply(this.tileSize); + size = size.multiply(this.tileSize); + context.translate(pos.x, this.canvas.height - pos.y); + context.rotate(angle); + context.scale(mirror?-size.x:size.x, size.y); + drawFunction(context); + context.restore(); + } + + drawTile(pos, size=vec2(1), tileIndex=0, tileSize=defaultTileSize, color=new Color, angle=0, mirror) + { + // draw a tile directly onto the layer canvas + this.drawCanvas2D(pos, size, angle, mirror, (context)=> + { + if (tileIndex < 0) + { + // untextured + context.fillStyle = color.rgba(); + context.fillRect(-.5, -.5, 1, 1); + } + else + { + const cols = tileImage.width/tileSize.x; + context.globalAlpha = color.a; // full color not supported in this mode + context.drawImage(tileImage, + (tileIndex%cols)*tileSize.x, (tileIndex/cols|0)*tileSize.x, + tileSize.x, tileSize.y, -.5, -.5, 1, 1); + } + }); + } + + drawRect(pos, size, color, angle) { this.drawTile(pos, size, -1, 0, color, angle, 0); } +} \ No newline at end of file diff --git a/Games/Space_Dominators/engine/engineUtil.js b/Games/Space_Dominators/engine/engineUtil.js new file mode 100644 index 0000000000..144862940b --- /dev/null +++ b/Games/Space_Dominators/engine/engineUtil.js @@ -0,0 +1,137 @@ + +/* + LittleJS Utility Classes and Functions + - Vector2 - fast, simple, easy vector class + - Color - holds a rgba color with math functions + - Timer - tracks time automatically + - Small math lib +*/ + +'use strict'; + +/////////////////////////////////////////////////////////////////////////////// +// helper functions + +const PI = Math.PI; +const abs = (a)=> a < 0 ? -a : a; +const sign = (a)=> a < 0 ? -1 : 1; +const min = (a, b)=> a < b ? a : b; +const max = (a, b)=> a > b ? a : b; +const mod = (a, b)=> ((a % b) + b) % b; +const clamp = (v, max=1, min=0)=> (ASSERT(max > min), v < min ? min : v > max ? max : v); +const percent = (v, max=1, min=0)=> max-min ? clamp((v-min) / (max-min)) : 0; +const lerp = (p, max=1, min=0)=> min + clamp(p) * (max-min); +const formatTime = (t)=> (t/60|0)+':'+(t%60<10?'0':'')+(t%60|0); +const isOverlapping = (pA, sA, pB, sB)=> abs(pA.x - pB.x)*2 < sA.x + sB.x & abs(pA.y - pB.y)*2 < sA.y + sB.y; + +// random functions +const rand = (a=1, b=0)=> b + (a-b)*Math.random(); +const randSign = ()=> (rand(2)|0)*2-1; +const randInCircle = (radius=1, minRadius=0)=> radius > 0 ? randVector(radius * rand(minRadius / radius, 1)**.5) : new Vector2; +const randVector = (length=1)=> new Vector2().setAngle(rand(2*PI), length); +const randColor = (cA = new Color, cB = new Color(0,0,0,1), linear)=> + linear ? cA.lerp(cB, rand()) : new Color(rand(cA.r,cB.r),rand(cA.g,cB.g),rand(cA.b,cB.b),rand(cA.a,cB.a)); + +// seeded random numbers - Xorshift +let randSeed = 0; +const randSeeded = (a=1, b=0)=> b + (a-b)* (Math.sin(++randSeed)**2 * 1e9 % 1); + +// create a 2d vector, can take another Vector2 to copy, 2 scalars, or 1 scalar +const vec2 = (x=0, y)=> x.x == undefined? new Vector2(x, y == undefined? x : y) : new Vector2(x.x, x.y); + +/////////////////////////////////////////////////////////////////////////////// +class Vector2 +{ + constructor(x=0, y=0) { this.x = x; this.y = y; } + + // basic math operators, a vector or scaler can be passed in + copy() { return new Vector2(this.x, this.y); } + scale(s) { ASSERT(s.x==undefined); return new Vector2(this.x * s, this.y * s); } + add(v) { ASSERT(v.x!=undefined); return new Vector2(this.x + v.x, this.y + v.y); } + subtract(v) { ASSERT(v.x!=undefined); return new Vector2(this.x - v.x, this.y - v.y); } + multiply(v) { ASSERT(v.x!=undefined); return new Vector2(this.x * v.x, this.y * v.y); } + divide(v) { ASSERT(v.x!=undefined); return new Vector2(this.x / v.x, this.y / v.y); } + + // vector math operators + length() { return this.lengthSquared()**.5; } + lengthSquared() { return this.x**2 + this.y**2; } + distance(p) { return this.distanceSquared(p)**.5; } + distanceSquared(p) { return (this.x - p.x)**2 + (this.y - p.y)**2; } + normalize(length=1) { const l = this.length(); return l ? this.scale(length/l) : new Vector2(length); } + clampLength(length=1) { const l = this.length(); return l > length ? this.scale(length/l) : this; } + dot(v) { ASSERT(v.x!=undefined); return this.x*v.x + this.y*v.y; } + cross(v) { ASSERT(v.x!=undefined); return this.x*v.y - this.y*v.x; } + angle() { return Math.atan2(this.x, this.y); } + setAngle(a, length=1) { this.x = length*Math.sin(a); this.y = length*Math.cos(a); return this; } + rotate(a) { const c = Math.cos(a), s = Math.sin(a); return new Vector2(this.x*c-this.y*s, this.x*s+this.y*c); } + direction() { return abs(this.x) > abs(this.y) ? this.x < 0 ? 3 : 1 : this.y < 0 ? 2 : 0; } + flip() { return new Vector2(this.y, this.x); } + invert() { return new Vector2(this.y, -this.x); } + round() { return new Vector2(Math.round(this.x), Math.round(this.y)); } + lerp(v, p) { ASSERT(v.x!=undefined); return this.add(v.subtract(this).scale(clamp(p))); } + int() { return new Vector2(this.x|0, this.y|0); } + area() { return this.x * this.y; } + arrayCheck(arraySize) { return this.x >= 0 && this.y >= 0 && this.x < arraySize.x && this.y < arraySize.y; } +} + +/////////////////////////////////////////////////////////////////////////////// +class Color +{ + constructor(r=1, g=1, b=1, a=1) { this.r=r; this.g=g; this.b=b; this.a=a; } + + copy(c) { return new Color(this.r, this.g, this.b, this.a); } + add(c) { return new Color(this.r+c.r, this.g+c.g, this.b+c.b, this.a+c.a); } + subtract(c) { return new Color(this.r-c.r, this.g-c.g, this.b-c.b, this.a-c.a); } + multiply(c) { return new Color(this.r*c.r, this.g*c.g, this.b*c.b, this.a*c.a); } + scale(s,a=s){ return new Color(this.r*s, this.g*s, this.b*s, this.a*a); } + clamp() { return new Color(clamp(this.r), clamp(this.g), clamp(this.b), clamp(this.a)); } + lerp(c, p) { return this.add(c.subtract(this).scale(clamp(p))); } + mutate(amount=.05, alphaAmount=0) + { + return new Color + ( + this.r + rand(amount, -amount), + this.g + rand(amount, -amount), + this.b + rand(amount, -amount), + this.a + rand(alphaAmount, -alphaAmount) + ).clamp(); + } + rgba() + { + ASSERT(this.r>=0 && this.r<=1 && this.g>=0 && this.g<=1 && this.b>=0 && this.b<=1 && this.a>=0 && this.a<=1); + return `rgb(${this.r*255|0},${this.g*255|0},${this.b*255|0},${this.a})`; + } + rgbaInt() + { + ASSERT(this.r>=0 && this.r<=1 && this.g>=0 && this.g<=1 && this.b>=0 && this.b<=1 && this.a>=0 && this.a<=1); + return (this.r*255|0) + (this.g*255<<8) + (this.b*255<<16) + (this.a*255<<24); + } + setHSLA(h=0, s=0, l=1, a=1) + { + const q = l < .5 ? l*(1+s) : l+s-l*s, p = 2*l-q, + f = (p, q, t)=> + (t = ((t%1)+1)%1) < 1/6 ? p+(q-p)*6*t : + t < 1/2 ? q : + t < 2/3 ? p+(q-p)*(2/3-t)*6 : p; + + this.r = f(p, q, h + 1/3); + this.g = f(p, q, h); + this.b = f(p, q, h - 1/3); + this.a = a; + return this; + } +} + +/////////////////////////////////////////////////////////////////////////////// +class Timer +{ + constructor(timeLeft) { this.time = timeLeft == undefined ? undefined : time + timeLeft; this.setTime = timeLeft; } + + set(timeLeft=0) { this.time = time + timeLeft; this.setTime = timeLeft; } + unset() { this.time = undefined; } + isSet() { return this.time != undefined; } + active() { return time <= this.time; } // is set and has no time left + elapsed() { return time > this.time; } // is set and has time left + get() { return this.isSet()? time - this.time : 0; } + getPercent() { return this.isSet()? percent(this.time - time, 0, this.setTime) : 0; } +} \ No newline at end of file diff --git a/Games/Space_Dominators/engine/engineWebGL.js b/Games/Space_Dominators/engine/engineWebGL.js new file mode 100644 index 0000000000..6f074034f8 --- /dev/null +++ b/Games/Space_Dominators/engine/engineWebGL.js @@ -0,0 +1,326 @@ +/* + LittleJS WebGL Interface + - All webgl used by the engine is wrapped up here + - Can be disabled with glEnable to revert to 2D canvas rendering + - Batches sprite rendering on GPU for incredibly fast performance + - Sprite transform math is done in the shader where possible + - For normal stuff you won't need to call any functions in this file + - For advanced stuff there are helper functions to create shaders, textures, etc +*/ + +'use strict'; + +const glEnable = 1; // can run without gl (texured coloring will be disabled) +let glCanvas, glContext, glTileTexture, glShader, glPositionData, glColorData, + glBatchCount, glDirty, glAdditive, glShrinkTilesX, glShrinkTilesY, glOverlay; + +function glInit() +{ + if (!glEnable) return; + + // create the canvas and tile texture + glCanvas = document.createElement('canvas'); + glContext = glCanvas.getContext('webgl', {antialias:!pixelated}); + glTileTexture = glCreateTexture(tileImage); + glShrinkTilesX = tileBleedShrinkFix/tileImageSize.x; + glShrinkTilesY = tileBleedShrinkFix/tileImageSize.y; + + if (glOverlay) + { + // firefox is much faster without copying the gl buffer so we just overlay it with some tradeoffs + document.body.appendChild(glCanvas); + glCanvas.style = mainCanvas.style.cssText; + } + + // setup vertex and fragment shaders + glShader = glCreateProgram( + 'precision lowp float;'+ // use lowp for better performance + 'uniform mat4 m;'+ // transform matrix + 'attribute float a;'+ // angle + 'attribute vec2 p,s,t;'+ // position, size, uv + 'attribute vec4 c,b;'+ // color, additiveColor + 'varying vec2 v;'+ // return uv + 'varying vec4 d,e;'+ // return color, additiveColor + 'void main(){'+ // shader entry point + 'gl_Position=m*vec4((s*cos(-a)+vec2(-s.y,s.x)*sin(-a))*.5+p,1,1);'+// transform position + 'v=t;d=c;e=b;'+ // pass stuff to fragment shader + '}' // end of shader + , + 'precision lowp float;'+ // use lowp for better performance + 'varying vec2 v;'+ // uv + 'varying vec4 d,e;'+ // color, additiveColor + 'uniform sampler2D j;'+ // texture + 'void main(){'+ // shader entry point + 'gl_FragColor=texture2D(j,v)*d+e;'+ // modulate texture by color plus additive + '}' // end of shader + ); + + // init buffers + const glVertexData = new ArrayBuffer(MAX_BATCH * VERTICES_PER_QUAD * VERTEX_STRIDE); + glCreateBuffer(gl_ARRAY_BUFFER, glVertexData.byteLength, gl_DYNAMIC_DRAW); + glPositionData = new Float32Array(glVertexData); + glColorData = new Uint32Array(glVertexData); + + // setup the vertex data array + const initVertexAttribArray = (name, type, typeSize, size, normalize=0)=> + { + const location = glContext.getAttribLocation(glShader, name); + glContext.enableVertexAttribArray(location); + glContext.vertexAttribPointer(location, size, type, normalize, VERTEX_STRIDE, offset); + offset += size*typeSize; + } + let offset = glDirty = glBatchCount = 0; + initVertexAttribArray('a', gl_FLOAT, 4, 1); // angle + initVertexAttribArray('p', gl_FLOAT, 4, 2); // position + initVertexAttribArray('s', gl_FLOAT, 4, 2); // size + initVertexAttribArray('t', gl_FLOAT, 4, 2); // texture coords + initVertexAttribArray('c', gl_UNSIGNED_BYTE, 1, 4, 1); // color + initVertexAttribArray('b', gl_UNSIGNED_BYTE, 1, 4, 1); // additiveColor + + // use point filtering for pixelated rendering + glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_MIN_FILTER, pixelated ? gl_NEAREST : gl_LINEAR); + glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_MAG_FILTER, pixelated ? gl_NEAREST : gl_LINEAR); +} + +function glSetBlendMode(additive) +{ + if (!glEnable) return; + + if (additive != glAdditive) + glFlush(); + + // setup blending + glAdditive = additive; + + glContext.blendFunc(gl_SRC_ALPHA, additive ? gl_ONE : gl_ONE_MINUS_SRC_ALPHA) + /*glContext.blendFuncSeparate( + gl_SRC_ALPHA, additive ? gl_ONE : gl_ONE_MINUS_SRC_ALPHA, + gl_ONE, additive ? gl_ONE : gl_ONE_MINUS_SRC_ALPHA);*/ + glContext.enable(gl_BLEND); +} + +function glCompileShader(source, type) +{ + if (!glEnable) return; + + // build the shader + const shader = glContext.createShader(type); + glContext.shaderSource(shader, source); + glContext.compileShader(shader); + + // check for errors + if (debug && !glContext.getShaderParameter(shader, gl_COMPILE_STATUS)) + throw glContext.getShaderInfoLog(shader); + return shader; +} + +function glCreateProgram(vsSource, fsSource) +{ + if (!glEnable) return; + + // build the program + const program = glContext.createProgram(); + glContext.attachShader(program, glCompileShader(vsSource, gl_VERTEX_SHADER)); + glContext.attachShader(program, glCompileShader(fsSource, gl_FRAGMENT_SHADER)); + glContext.linkProgram(program); + + // check for errors + if (debug && !glContext.getProgramParameter(program, gl_LINK_STATUS)) + throw glContext.getProgramInfoLog(program); + return program; +} + +function glCreateBuffer(bufferType, size, usage) +{ + if (!glEnable) return; + + // build the buffer + const buffer = glContext.createBuffer(); + glContext.bindBuffer(bufferType, buffer); + glContext.bufferData(bufferType, size, usage); + return buffer; +} + +function glCreateTexture(image) +{ + if (!glEnable) return; + + // build the texture + const texture = glContext.createTexture(); + glContext.bindTexture(gl_TEXTURE_2D, texture); + glContext.texImage2D(gl_TEXTURE_2D, 0, gl_RGBA, gl_RGBA, gl_UNSIGNED_BYTE, image); + return texture; +} + +function glPreRender(width, height) +{ + if (!glEnable) return; + + // clear and set to same size as main canvas + glCanvas.width = width; + glCanvas.height = height; + glContext.viewport(0, 0, width, height); + + // set up the shader + glContext.useProgram(glShader); + glSetBlendMode(); + + // build the transform matrix + const sx = 2 * cameraScale / width; + const sy = 2 * cameraScale / height; + glContext.uniformMatrix4fv(glContext.getUniformLocation(glShader, 'm'), 0, + new Float32Array([ + sx, 0, 0, 0, + 0, sy, 0, 0, + 1, 1, -1, 1, + -1-sx*cameraPos.x, -1-sy*cameraPos.y, 0, 0 + ]) + ); +} + +function glFlush() +{ + if (!glEnable) return; + if (!glBatchCount) + return; + + // draw all the sprites in the batch and reset the buffer + glContext.bufferSubData(gl_ARRAY_BUFFER, 0, glPositionData.subarray(0, glBatchCount * VERTICES_PER_QUAD * VERTEX_STRIDE)); + glContext.drawArrays(gl_TRIANGLES, 0, glBatchCount * VERTICES_PER_QUAD); + glBatchCount = 0; +} + +function glCopyToContext(context, forceDraw) +{ + if (!glEnable) return; + if (!glDirty) return; + + // draw any sprites still in the buffer, copy to main canvas and clear + glFlush(); + + if (!glOverlay || forceDraw) + { + // do not draw/clear in overlay mode because the canvas is visible + context.drawImage(glCanvas, 0, glAdditive = glDirty = 0); + glContext.clear(gl_COLOR_BUFFER_BIT); + } +} + +function glDraw(x, y, sizeX, sizeY, angle, mirror, uv0X, uv0Y, uv1X, uv1Y, abgr, abgrAdditive) +{ + if (!glEnable) return; + + // flush if there is no room for more verts + if (glBatchCount >= MAX_BATCH) + glFlush(); + + if (tileBleedShrinkFix) + { + // shrink tiles to prevent bleeding + uv0X += glShrinkTilesX; + uv0Y += glShrinkTilesY; + uv1X -= glShrinkTilesX; + uv1Y -= glShrinkTilesY; + } + + // setup 2 triangles to form a quad + let offset = glBatchCount++ * VERTICES_PER_QUAD * INDICIES_PER_VERT - 1; + sizeX = mirror ? -sizeX : sizeX; + glDirty = 1; + + // vertex 0 + glPositionData[++offset] = angle; + glPositionData[++offset] = x; + glPositionData[++offset] = y; + glPositionData[++offset] = -sizeX; + glPositionData[++offset] = -sizeY; + glPositionData[++offset] = uv0X; + glPositionData[++offset] = uv1Y; + glColorData[++offset] = abgr; + glColorData[++offset] = abgrAdditive; + + // vertex 1 + glPositionData[++offset] = angle; + glPositionData[++offset] = x; + glPositionData[++offset] = y; + glPositionData[++offset] = sizeX; + glPositionData[++offset] = sizeY; + glPositionData[++offset] = uv1X; + glPositionData[++offset] = uv0Y; + glColorData[++offset] = abgr; + glColorData[++offset] = abgrAdditive; + + // vertex 2 + glPositionData[++offset] = angle; + glPositionData[++offset] = x; + glPositionData[++offset] = y; + glPositionData[++offset] = -sizeX; + glPositionData[++offset] = sizeY; + glPositionData[++offset] = uv0X; + glPositionData[++offset] = uv0Y; + glColorData[++offset] = abgr; + glColorData[++offset] = abgrAdditive; + + // vertex 0 + glPositionData[++offset] = angle; + glPositionData[++offset] = x; + glPositionData[++offset] = y; + glPositionData[++offset] = -sizeX; + glPositionData[++offset] = -sizeY; + glPositionData[++offset] = uv0X; + glPositionData[++offset] = uv1Y; + glColorData[++offset] = abgr; + glColorData[++offset] = abgrAdditive; + + // vertex 3 + glPositionData[++offset] = angle; + glPositionData[++offset] = x; + glPositionData[++offset] = y; + glPositionData[++offset] = sizeX; + glPositionData[++offset] = -sizeY; + glPositionData[++offset] = uv1X; + glPositionData[++offset] = uv1Y; + glColorData[++offset] = abgr; + glColorData[++offset] = abgrAdditive; + + // vertex 1 + glPositionData[++offset] = angle; + glPositionData[++offset] = x; + glPositionData[++offset] = y; + glPositionData[++offset] = sizeX; + glPositionData[++offset] = sizeY; + glPositionData[++offset] = uv1X; + glPositionData[++offset] = uv0Y; + glColorData[++offset] = abgr; + glColorData[++offset] = abgrAdditive; +} + +/////////////////////////////////////////////////////////////////////////////// +// store gl constants as integers so their name doesn't use space in minifed +const +gl_ONE = 1, +gl_TRIANGLES = 4, +gl_SRC_ALPHA = 770, +gl_ONE_MINUS_SRC_ALPHA = 771, +gl_BLEND = 3042, +gl_TEXTURE_2D = 3553, +gl_UNSIGNED_BYTE = 5121, +gl_FLOAT = 5126, +gl_RGBA = 6408, +gl_NEAREST = 9728, +gl_LINEAR = 9729, +gl_TEXTURE_MAG_FILTER = 10240, +gl_TEXTURE_MIN_FILTER = 10241, +gl_COLOR_BUFFER_BIT = 16384, +gl_ARRAY_BUFFER = 34962, +gl_DYNAMIC_DRAW = 35048, +gl_FRAGMENT_SHADER = 35632, +gl_VERTEX_SHADER = 35633, +gl_COMPILE_STATUS = 35713, +gl_LINK_STATUS = 35714, + +// constants for batch rendering +VERTICES_PER_QUAD = 6, +INDICIES_PER_VERT = 9, +MAX_BATCH = 1<<16, +VERTEX_STRIDE = 4 + (4 * 2) * 3 + (4) * 2; // float + vec2 * 3 + (char * 4) * 2 \ No newline at end of file diff --git a/Games/Space_Dominators/favicon.ico b/Games/Space_Dominators/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ac18b72c511ed9a564e7db7734404092677cd104 GIT binary patch literal 766 zcmZQzU<5)11py$*!tjELfkBLcfk6X^6@b_Qh(Y4`!G8wZDQOI0-xuLmMjKfrWn&0{ z8czehprD|!p&-J1YV&F8eg-BbC{Td-fDoU7!PtO-f!P@9Um!(@k5EY}>pv8HU|_JP X5@rXQ1IPb>Y#9E3Ai*Avq4oj*t4~>G literal 0 HcmV?d00001 diff --git a/Games/Space_Dominators/index.html b/Games/Space_Dominators/index.html new file mode 100644 index 0000000000..c2e9de8635 --- /dev/null +++ b/Games/Space_Dominators/index.html @@ -0,0 +1,25 @@ + + space dominator + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Games/Space_Dominators/package.json b/Games/Space_Dominators/package.json new file mode 100644 index 0000000000..5ad8eac549 --- /dev/null +++ b/Games/Space_Dominators/package.json @@ -0,0 +1,12 @@ +{ + "name": "space-dominator", + "version": "1.0.0", + "main": "app.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "" +} diff --git a/Games/Space_Dominators/screenshot.png b/Games/Space_Dominators/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..ef11bb7e64eeeb0d5c33e37450cb7188386a6eaa GIT binary patch literal 73137 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 z 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 literal 0 HcmV?d00001 diff --git a/Games/Space_Dominators/tiles.png b/Games/Space_Dominators/tiles.png new file mode 100644 index 0000000000000000000000000000000000000000..95d42006485e2ed18d8cc183b1f1d19925d37d57 GIT binary patch literal 7857 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 P00000NkvXXu0mjf@^IQA literal 0 HcmV?d00001