repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
Robin-hood Game
stars
latest
clone command
git clone gitlawb://did:key:z6MkwMR9...adEc/robin-hood-gamegit clone gitlawb://did:key:z6MkwMR9.../robin-hood-gamec8777c8csync from playground7h ago| #1 | import { |
| #2 | GAME_SIZE, GRAVITY, JUMP_FORCE, GROUND_Y, |
| #3 | ROBIN_WIDTH, ROBIN_HEIGHT, |
| #4 | SCROLL_SPEED_INITIAL, SCROLL_SPEED_INCREMENT, |
| #5 | OBSTACLE_SPAWN_MIN, OBSTACLE_SPAWN_MAX, |
| #6 | ENEMY_SPAWN_MIN, ENEMY_SPAWN_MAX, |
| #7 | COIN_SPAWN_MIN, COIN_SPAWN_MAX, |
| #8 | ARROW_SPEED, ARROW_COST, |
| #9 | INITIAL_LIVES, INITIAL_COINS, |
| #10 | INVULNERABILITY_FRAMES, ENVIRONMENT_DURATION, |
| #11 | ENVIRONMENTS, |
| #12 | type Environment, |
| #13 | } from './constants'; |
| #14 | import type { |
| #15 | GameState, Robin, Obstacle, Enemy, CoinBag, Arrow, Particle, |
| #16 | ObstacleType, EnemyType, BackgroundLayer, SoundEvent, |
| #17 | } from './types'; |
| #18 | |
| #19 | function randomRange(min: number, max: number): number { |
| #20 | return min + Math.random() * (max - min); |
| #21 | } |
| #22 | |
| #23 | function randomInt(min: number, max: number): number { |
| #24 | return Math.floor(randomRange(min, max + 1)); |
| #25 | } |
| #26 | |
| #27 | function rectsOverlap( |
| #28 | ax: number, ay: number, aw: number, ah: number, |
| #29 | bx: number, by: number, bw: number, bh: number, |
| #30 | ): boolean { |
| #31 | return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; |
| #32 | } |
| #33 | |
| #34 | function getObstacleDimensions(type: ObstacleType): { w: number; h: number } { |
| #35 | switch (type) { |
| #36 | case 'barrel': return { w: 22, h: 28 }; |
| #37 | case 'branch': return { w: 40, h: 16 }; |
| #38 | case 'rock': return { w: 30, h: 22 }; |
| #39 | case 'fence': return { w: 36, h: 30 }; |
| #40 | case 'crate': return { w: 26, h: 26 }; |
| #41 | } |
| #42 | } |
| #43 | |
| #44 | function getEnemyDimensions(type: EnemyType): { w: number; h: number } { |
| #45 | switch (type) { |
| #46 | case 'guard': return { w: 24, h: 38 }; |
| #47 | case 'knight': return { w: 26, h: 40 }; |
| #48 | case 'bandit': return { w: 22, h: 36 }; |
| #49 | case 'wolf': return { w: 32, h: 24 }; |
| #50 | case 'archer': return { w: 22, h: 36 }; |
| #51 | } |
| #52 | } |
| #53 | |
| #54 | function getEnemyHealth(type: EnemyType): number { |
| #55 | switch (type) { |
| #56 | case 'wolf': return 1; |
| #57 | case 'bandit': return 1; |
| #58 | case 'archer': return 1; |
| #59 | case 'guard': return 2; |
| #60 | case 'knight': return 2; |
| #61 | } |
| #62 | } |
| #63 | |
| #64 | function spawnParticles(particles: Particle[], x: number, y: number, count: number, color: string) { |
| #65 | for (let i = 0; i < count; i++) { |
| #66 | particles.push({ |
| #67 | x, y, |
| #68 | velX: randomRange(-3, 3) * 4, // scaled for 15fps (was 60fps) |
| #69 | velY: randomRange(-5, -1) * 4, |
| #70 | life: randomRange(4, 9), // fewer ticks but each moves further |
| #71 | maxLife: 9, |
| #72 | color, |
| #73 | size: randomRange(2, 4), |
| #74 | }); |
| #75 | } |
| #76 | } |
| #77 | |
| #78 | export function createInitialState(): GameState { |
| #79 | return { |
| #80 | robin: { |
| #81 | x: 60, |
| #82 | y: GROUND_Y - ROBIN_HEIGHT, |
| #83 | width: ROBIN_WIDTH, |
| #84 | height: ROBIN_HEIGHT, |
| #85 | velX: 0, |
| #86 | velY: 0, |
| #87 | onGround: true, |
| #88 | invulnerable: 0, |
| #89 | animFrame: 0, |
| #90 | animTimer: 0, |
| #91 | shooting: false, |
| #92 | shootTimer: 0, |
| #93 | }, |
| #94 | obstacles: [], |
| #95 | enemies: [], |
| #96 | coinBags: [], |
| #97 | arrows: [], |
| #98 | particles: [], |
| #99 | scrollSpeed: SCROLL_SPEED_INITIAL, |
| #100 | score: 0, |
| #101 | coins: INITIAL_COINS, |
| #102 | lives: INITIAL_LIVES, |
| #103 | gameOver: false, |
| #104 | started: false, |
| #105 | environment: 'forest', |
| #106 | envTimer: 0, |
| #107 | spawnTimerObstacle: 60, |
| #108 | spawnTimerEnemy: 120, |
| #109 | spawnTimerCoin: 100, |
| #110 | frameCount: 0, |
| #111 | distance: 0, |
| #112 | bgLayers: [ |
| #113 | { x: 0, speed: 0.3, elements: [] }, |
| #114 | { x: 0, speed: 0.7, elements: [] }, |
| #115 | ], |
| #116 | flashTimer: 0, |
| #117 | highScore: 0, |
| #118 | soundEvents: [], |
| #119 | }; |
| #120 | } |
| #121 | |
| #122 | export function resetGame(state: GameState): GameState { |
| #123 | const highScore = state.highScore; |
| #124 | const fresh = createInitialState(); |
| #125 | fresh.started = true; |
| #126 | fresh.highScore = highScore; |
| #127 | return fresh; |
| #128 | } |
| #129 | |
| #130 | export function updateGame(state: GameState): GameState { |
| #131 | if (!state.started || state.gameOver) { |
| #132 | return { ...state, soundEvents: [] }; |
| #133 | } |
| #134 | |
| #135 | const s = { ...state }; |
| #136 | s.soundEvents = []; // clear events from last frame |
| #137 | s.frameCount++; |
| #138 | s.scrollSpeed = SCROLL_SPEED_INITIAL + s.frameCount * SCROLL_SPEED_INCREMENT; |
| #139 | s.distance += s.scrollSpeed; |
| #140 | s.score = Math.floor(s.distance / 10); |
| #141 | |
| #142 | // Update robin |
| #143 | const robin = { ...s.robin }; |
| #144 | robin.animTimer++; |
| #145 | if (robin.animTimer > 4) { |
| #146 | robin.animFrame++; |
| #147 | robin.animTimer = 0; |
| #148 | } |
| #149 | |
| #150 | // Gravity |
| #151 | if (!robin.onGround) { |
| #152 | robin.velY += GRAVITY; |
| #153 | robin.y += robin.velY; |
| #154 | |
| #155 | // Clamp: don't let Robin go above the top of the screen |
| #156 | const CEILING = 10; |
| #157 | if (robin.y < CEILING) { |
| #158 | robin.y = CEILING; |
| #159 | robin.velY = 0; |
| #160 | } |
| #161 | } |
| #162 | |
| #163 | // Ground collision |
| #164 | if (robin.y >= GROUND_Y - robin.height) { |
| #165 | robin.y = GROUND_Y - robin.height; |
| #166 | robin.velY = 0; |
| #167 | robin.onGround = true; |
| #168 | } |
| #169 | |
| #170 | // Invulnerability |
| #171 | if (robin.invulnerable > 0) { |
| #172 | robin.invulnerable--; |
| #173 | } |
| #174 | |
| #175 | // Shooting cooldown |
| #176 | if (robin.shooting) { |
| #177 | robin.shootTimer--; |
| #178 | if (robin.shootTimer <= 0) { |
| #179 | robin.shooting = false; |
| #180 | } |
| #181 | } |
| #182 | |
| #183 | s.robin = robin; |
| #184 | |
| #185 | // Update obstacles |
| #186 | let obstacles = s.obstacles |
| #187 | .map(o => ({ ...o, x: o.x - s.scrollSpeed })) |
| #188 | .filter(o => o.x + o.width > -20); |
| #189 | |
| #190 | // Spawn obstacles |
| #191 | s.spawnTimerObstacle--; |
| #192 | if (s.spawnTimerObstacle <= 0) { |
| #193 | const types: ObstacleType[] = ['barrel', 'branch', 'rock', 'fence', 'crate']; |
| #194 | const type = types[randomInt(0, types.length - 1)]; |
| #195 | const dim = getObstacleDimensions(type); |
| #196 | obstacles.push({ |
| #197 | x: GAME_SIZE + 10, |
| #198 | y: GROUND_Y - dim.h, |
| #199 | width: dim.w, |
| #200 | height: dim.h, |
| #201 | velX: 0, |
| #202 | velY: 0, |
| #203 | type, |
| #204 | animFrame: 0, |
| #205 | }); |
| #206 | s.spawnTimerObstacle = randomInt(OBSTACLE_SPAWN_MIN, OBSTACLE_SPAWN_MAX); |
| #207 | } |
| #208 | |
| #209 | // Check obstacle collisions |
| #210 | for (const obs of obstacles) { |
| #211 | if ( |
| #212 | robin.invulnerable <= 0 && |
| #213 | rectsOverlap( |
| #214 | robin.x + 4, robin.y + 4, robin.width - 8, robin.height - 4, |
| #215 | obs.x, obs.y, obs.width, obs.height, |
| #216 | ) |
| #217 | ) { |
| #218 | s.lives--; |
| #219 | robin.invulnerable = INVULNERABILITY_FRAMES; |
| #220 | s.flashTimer = 4; // ~15fps equivalent of 15@60fps |
| #221 | s.soundEvents.push('hurt'); |
| #222 | spawnParticles(s.particles, robin.x + robin.width / 2, robin.y + robin.height / 2, 8, '#ff3333'); |
| #223 | if (s.lives <= 0) { |
| #224 | s.gameOver = true; |
| #225 | s.soundEvents.push('gameOver'); |
| #226 | if (s.score > s.highScore) s.highScore = s.score; |
| #227 | } |
| #228 | s.robin = robin; |
| #229 | break; |
| #230 | } |
| #231 | } |
| #232 | s.obstacles = obstacles; |
| #233 | |
| #234 | // Update enemies |
| #235 | let enemies = s.enemies |
| #236 | .map(e => { |
| #237 | const ne = { ...e, x: e.x - s.scrollSpeed }; |
| #238 | ne.animTimer++; |
| #239 | if (ne.animTimer > 6) { |
| #240 | ne.animFrame++; |
| #241 | ne.animTimer = 0; |
| #242 | } |
| #243 | return ne; |
| #244 | }) |
| #245 | .filter(e => e.x + e.width > -40 && e.health > 0); |
| #246 | |
| #247 | // Spawn enemies |
| #248 | s.spawnTimerEnemy--; |
| #249 | if (s.spawnTimerEnemy <= 0) { |
| #250 | const types: EnemyType[] = ['guard', 'knight', 'bandit', 'wolf', 'archer']; |
| #251 | const type = types[randomInt(0, types.length - 1)]; |
| #252 | const dim = getEnemyDimensions(type); |
| #253 | enemies.push({ |
| #254 | x: GAME_SIZE + 10, |
| #255 | y: GROUND_Y - dim.h, |
| #256 | width: dim.w, |
| #257 | height: dim.h, |
| #258 | velX: 0, |
| #259 | velY: 0, |
| #260 | type, |
| #261 | health: getEnemyHealth(type), |
| #262 | animFrame: 0, |
| #263 | animTimer: 0, |
| #264 | }); |
| #265 | s.spawnTimerEnemy = randomInt(ENEMY_SPAWN_MIN, ENEMY_SPAWN_MAX); |
| #266 | } |
| #267 | |
| #268 | // Check enemy proximity damage |
| #269 | for (const enemy of enemies) { |
| #270 | const margin = 8; |
| #271 | if ( |
| #272 | robin.invulnerable <= 0 && |
| #273 | rectsOverlap( |
| #274 | robin.x, robin.y, robin.width, robin.height, |
| #275 | enemy.x - margin, enemy.y - margin, enemy.width + margin * 2, enemy.height + margin * 2, |
| #276 | ) |
| #277 | ) { |
| #278 | s.lives--; |
| #279 | robin.invulnerable = INVULNERABILITY_FRAMES; |
| #280 | s.flashTimer = 4; |
| #281 | s.soundEvents.push('hurt'); |
| #282 | spawnParticles(s.particles, robin.x + robin.width / 2, robin.y + robin.height / 2, 8, '#ff3333'); |
| #283 | if (s.lives <= 0) { |
| #284 | s.gameOver = true; |
| #285 | s.soundEvents.push('gameOver'); |
| #286 | if (s.score > s.highScore) s.highScore = s.score; |
| #287 | } |
| #288 | s.robin = robin; |
| #289 | break; |
| #290 | } |
| #291 | } |
| #292 | |
| #293 | s.enemies = enemies; |
| #294 | |
| #295 | // Update coin bags |
| #296 | let coinBags = s.coinBags |
| #297 | .map(c => { |
| #298 | const nc = { ...c, x: c.x - s.scrollSpeed }; |
| #299 | nc.animTimer++; |
| #300 | if (nc.animTimer > 8) { |
| #301 | nc.animFrame++; |
| #302 | nc.animTimer = 0; |
| #303 | } |
| #304 | nc.sparkle = Math.max(0, nc.sparkle - 0.4); // scaled for 15fps |
| #305 | return nc; |
| #306 | }) |
| #307 | .filter(c => !c.collected && c.x + c.width > -20); |
| #308 | |
| #309 | // Spawn coins |
| #310 | s.spawnTimerCoin--; |
| #311 | if (s.spawnTimerCoin <= 0) { |
| #312 | coinBags.push({ |
| #313 | x: GAME_SIZE + 10, |
| #314 | y: GROUND_Y - 30 - randomInt(0, 60), |
| #315 | width: 20, |
| #316 | height: 24, |
| #317 | velX: 0, |
| #318 | velY: 0, |
| #319 | collected: false, |
| #320 | animFrame: 0, |
| #321 | animTimer: 0, |
| #322 | sparkle: 3, |
| #323 | }); |
| #324 | s.spawnTimerCoin = randomInt(COIN_SPAWN_MIN, COIN_SPAWN_MAX); |
| #325 | } |
| #326 | |
| #327 | // Check coin collection |
| #328 | for (const bag of coinBags) { |
| #329 | if (!bag.collected) { |
| #330 | const dx = (robin.x + robin.width / 2) - (bag.x + bag.width / 2); |
| #331 | const dy = (robin.y + robin.height / 2) - (bag.y + bag.height / 2); |
| #332 | const dist = Math.sqrt(dx * dx + dy * dy); |
| #333 | if (dist < 30) { |
| #334 | bag.collected = true; |
| #335 | s.coins += randomInt(3, 8); |
| #336 | s.score += 10; |
| #337 | s.soundEvents.push('coin'); |
| #338 | spawnParticles(s.particles, bag.x + bag.width / 2, bag.y + bag.height / 2, 6, '#FFD700'); |
| #339 | } |
| #340 | } |
| #341 | } |
| #342 | s.coinBags = coinBags; |
| #343 | |
| #344 | // Update arrows |
| #345 | let arrows = s.arrows |
| #346 | .map(a => ({ ...a, x: a.x + ARROW_SPEED })) |
| #347 | .filter(a => a.x < GAME_SIZE + 20); |
| #348 | |
| #349 | // Arrow vs enemy collision |
| #350 | for (const arrow of arrows) { |
| #351 | if (!arrow.alive) continue; |
| #352 | for (const enemy of enemies) { |
| #353 | if ( |
| #354 | rectsOverlap( |
| #355 | arrow.x, arrow.y, 18, 4, |
| #356 | enemy.x, enemy.y, enemy.width, enemy.height, |
| #357 | ) |
| #358 | ) { |
| #359 | arrow.alive = false; |
| #360 | enemy.health--; |
| #361 | s.soundEvents.push('hitEnemy'); |
| #362 | spawnParticles(s.particles, arrow.x + 9, arrow.y + 2, 4, '#ffaa00'); |
| #363 | if (enemy.health <= 0) { |
| #364 | s.score += 25; |
| #365 | s.soundEvents.push('killEnemy'); |
| #366 | spawnParticles(s.particles, enemy.x + enemy.width / 2, enemy.y + enemy.height / 2, 10, '#ff6600'); |
| #367 | } |
| #368 | break; |
| #369 | } |
| #370 | } |
| #371 | } |
| #372 | s.arrows = arrows.filter(a => a.alive); |
| #373 | |
| #374 | // Update particles |
| #375 | s.particles = s.particles |
| #376 | .map(p => ({ |
| #377 | ...p, |
| #378 | x: p.x + p.velX, |
| #379 | y: p.y + p.velY, |
| #380 | velY: p.velY + 0.6, // scaled for 15fps (was 0.15 at 60fps) |
| #381 | life: p.life - 1, |
| #382 | })) |
| #383 | .filter(p => p.life > 0); |
| #384 | |
| #385 | // Flash timer |
| #386 | if (s.flashTimer > 0) s.flashTimer--; |
| #387 | |
| #388 | // Environment change |
| #389 | s.envTimer++; |
| #390 | if (s.envTimer >= ENVIRONMENT_DURATION) { |
| #391 | s.envTimer = 0; |
| #392 | let newEnv: Environment; |
| #393 | do { |
| #394 | newEnv = ENVIRONMENTS[randomInt(0, ENVIRONMENTS.length - 1)]; |
| #395 | } while (newEnv === s.environment); |
| #396 | s.environment = newEnv; |
| #397 | s.soundEvents.push('envChange'); |
| #398 | } |
| #399 | |
| #400 | return s; |
| #401 | } |
| #402 | |
| #403 | export function jump(state: GameState): GameState { |
| #404 | if (!state.started) { |
| #405 | return { ...state, started: true, soundEvents: ['jump'] }; |
| #406 | } |
| #407 | if (state.gameOver) { |
| #408 | return resetGame(state); |
| #409 | } |
| #410 | if (state.robin.onGround) { |
| #411 | return { |
| #412 | ...state, |
| #413 | robin: { |
| #414 | ...state.robin, |
| #415 | velY: JUMP_FORCE, |
| #416 | onGround: false, |
| #417 | }, |
| #418 | soundEvents: ['jump'], |
| #419 | }; |
| #420 | } |
| #421 | return state; |
| #422 | } |
| #423 | |
| #424 | export function shoot(state: GameState): GameState { |
| #425 | if (!state.started || state.gameOver) return state; |
| #426 | if (state.coins < ARROW_COST) return state; |
| #427 | if (state.robin.shooting) return state; |
| #428 | |
| #429 | return { |
| #430 | ...state, |
| #431 | coins: state.coins - ARROW_COST, |
| #432 | robin: { |
| #433 | ...state.robin, |
| #434 | shooting: true, |
| #435 | shootTimer: 4, // 15fps equivalent of 15@60fps |
| #436 | }, |
| #437 | arrows: [ |
| #438 | ...state.arrows, |
| #439 | { |
| #440 | x: state.robin.x + state.robin.width, |
| #441 | y: state.robin.y + state.robin.height / 2 - 2, |
| #442 | width: 18, |
| #443 | height: 4, |
| #444 | velX: ARROW_SPEED, |
| #445 | velY: 0, |
| #446 | alive: true, |
| #447 | }, |
| #448 | ], |
| #449 | soundEvents: ['shoot'], |
| #450 | }; |
| #451 | } |
| #452 |