From 6881dce8f9bd46db7c68a36d90b08787ca1e78b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grzegorz=20Ta=C5=84czyk?= Date: Fri, 23 Aug 2024 22:58:50 +0300 Subject: [PATCH] playable levels --- .../src/game-states/gameplay/bonus-logic.ts | 241 +++++++++++++++++ .../src/game-states/gameplay/game-logic.ts | 242 ++---------------- .../src/game-states/gameplay/game-render.ts | 2 +- .../game-states/gameplay/gameplay-types.ts | 3 +- .../game-states/gameplay/level-generator.ts | 15 +- .../src/game-states/gameplay/level-updater.ts | 14 + .../gameplay/levels/04-crush-and-rush.ts | 8 +- .../gameplay/levels/05-tick-tock-boom.ts | 14 +- .../gameplay/levels/07-monsters-mayhem.ts | 16 +- .../gameplay/levels/08-tunnel-vision.ts | 35 ++- .../gameplay/levels/09-ghost-bomber.ts | 45 +++- .../levels/10-crush-confuse-conquer.ts | 32 --- .../gameplay/levels/10-surf-and-climb.ts | 56 ++++ .../gameplay/levels/11-slide-and-blast.ts | 56 ++++ .../gameplay/levels/11-the-triple-threat.ts | 33 --- .../gameplay/levels/12-the-gauntlet.ts | 59 +++-- .../gameplay/levels/13-the-final-countdown.ts | 132 +++++++--- .../src/game-states/gameplay/monster-spawn.ts | 78 ++++++ .../src/game-states/gameplay/move-utils.ts | 2 +- .../gameplay/render/bonus-render.ts | 2 + .../gameplay/render/grid-render.ts | 4 +- .../src/game-states/intro/game-preview.tsx | 3 +- js13k2024/prompts/15_playable_levels.md | 20 +- 23 files changed, 705 insertions(+), 407 deletions(-) create mode 100644 js13k2024/game/src/game-states/gameplay/bonus-logic.ts create mode 100644 js13k2024/game/src/game-states/gameplay/level-updater.ts delete mode 100644 js13k2024/game/src/game-states/gameplay/levels/10-crush-confuse-conquer.ts create mode 100644 js13k2024/game/src/game-states/gameplay/levels/10-surf-and-climb.ts create mode 100644 js13k2024/game/src/game-states/gameplay/levels/11-slide-and-blast.ts delete mode 100644 js13k2024/game/src/game-states/gameplay/levels/11-the-triple-threat.ts create mode 100644 js13k2024/game/src/game-states/gameplay/monster-spawn.ts diff --git a/js13k2024/game/src/game-states/gameplay/bonus-logic.ts b/js13k2024/game/src/game-states/gameplay/bonus-logic.ts new file mode 100644 index 00000000..d807eca0 --- /dev/null +++ b/js13k2024/game/src/game-states/gameplay/bonus-logic.ts @@ -0,0 +1,241 @@ +import { soundEngine } from '../../sound/sound-engine'; +import { startGameOverAnimation } from './game-logic'; +import { + GameState, + BonusType, + ActiveBonus, + LevelConfig, + Position, + isActiveBonus, + Direction, + BlasterShot, +} from './gameplay-types'; +import { + isPositionOccupied, + isPositionEqual, + manhattanDistance, + getNewPosition, + isValidMove, + getDirectionFromPositions, + isValidObstaclePush, +} from './move-utils'; +import { BLASTER_SHOT_DURATION } from './render/animation-utils'; + +export const applyBonus = (gameState: GameState, bonusType: BonusType) => { + const newActiveBonus: ActiveBonus = { type: bonusType, duration: 13 }; + gameState.activeBonuses.push(newActiveBonus); + + switch (bonusType) { + case BonusType.CapOfInvisibility: + // Logic for Cap of Invisibility is handled in monster movement + break; + case BonusType.ConfusedMonsters: + // Logic for Confused Monsters is handled in monster movement + break; + case BonusType.LandMine: + gameState.landMines.push({ ...gameState.player.position }); + break; + case BonusType.TimeBomb: + gameState.timeBombs.push({ + position: gameState.player.position, + timer: 13, + shakeIntensity: 0, + }); + break; + case BonusType.Crusher: + break; + case BonusType.Builder: + break; + case BonusType.Climber: + break; + case BonusType.Teleport: + // Teleport is handled immediately when collected + break; + case BonusType.Tsunami: + gameState.tsunamiLevel = 1; + break; + case BonusType.Monster: + break; + case BonusType.Slide: + break; + case BonusType.Sokoban: + break; + case BonusType.Blaster: + break; + } +}; + +export const spawnDynamicBonus = (gameState: GameState, levelConfig: LevelConfig): void => { + const availableBonusTypes = Object.values(BonusType).filter((type) => typeof type === 'number') as BonusType[]; + let selectedBonusType: BonusType; + + // Analyze the game state to determine the most appropriate bonus + if (gameState.monsters.length > 5) { + // If there are many monsters, prioritize defensive bonuses + const defensiveBonuses = [ + BonusType.CapOfInvisibility, + BonusType.Crusher, + BonusType.LandMine, + BonusType.TimeBomb, + BonusType.Blaster, + ]; + selectedBonusType = defensiveBonuses[Math.floor(Math.random() * defensiveBonuses.length)]; + } else if (gameState.obstacles.length > 20) { + // If there are many obstacles, prioritize movement-related bonuses + const movementBonuses = [BonusType.Climber, BonusType.Slide, BonusType.Teleport]; + selectedBonusType = movementBonuses[Math.floor(Math.random() * movementBonuses.length)]; + } else { + // Otherwise, choose a random bonus type + selectedBonusType = availableBonusTypes[Math.floor(Math.random() * availableBonusTypes.length)]; + } + + // Find a valid position for the new bonus + let position: Position; + do { + position = { + x: Math.floor(Math.random() * levelConfig.gridSize), + y: Math.floor(Math.random() * levelConfig.gridSize), + }; + } while ( + isPositionOccupied( + position, + gameState.obstacles.map((o) => o.position), + ) || + isPositionOccupied( + position, + gameState.monsters.map((m) => m.position), + ) || + isPositionOccupied( + position, + gameState.bonuses.map((b) => b.position), + ) || + isPositionEqual(position, gameState.player.position) || + isPositionEqual(position, gameState.goal) || + manhattanDistance(position, gameState.player.position) < 3 + ); + + // Add the new bonus to the game state + gameState.bonuses.push({ + type: selectedBonusType, + position: position, + }); +}; + +export const performTeleportation = (gameState: GameState, teleportPoint: Position): void => { + const destinationPoint = gameState.bonuses.find( + (bonus) => bonus.type === BonusType.Teleport && !isPositionEqual(bonus.position, teleportPoint), + ); + if (destinationPoint) { + gameState.player.previousPosition = gameState.player.position; + gameState.player.position = destinationPoint.position; + gameState.player.teleportTimestamp = Date.now(); + soundEngine.playTeleport(); + } +}; + +export const handleTsunamiEffect = (gameState: GameState): void => { + gameState.tsunamiLevel++; + if (gameState.tsunamiLevel >= 13) { + if (!isActiveBonus(gameState, BonusType.Climber)) { + gameState.gameEndingState = 'gameOver'; + startGameOverAnimation(gameState); + } + gameState.monsters = []; + gameState.tsunamiLevel = 0; + } +}; + +export const handleMonsterBonus = (gameState: GameState): void => { + // Check for collisions with monsters (now players) + const collidedMonster = gameState.monsters.find((monster) => + isPositionEqual(gameState.player.position, monster.position), + ); + if (collidedMonster) { + gameState.monsters = gameState.monsters.filter((monster) => monster !== collidedMonster); + if (gameState.monsters.length === 0) { + gameState.gameEndingState = 'gameOver'; + startGameOverAnimation(gameState); + } + } +}; + +export const handleSlideMovement = (gameState: GameState, direction: Direction, levelConfig: LevelConfig): Position => { + let newPosition = gameState.player.position; + while (isValidMove(getNewPosition(newPosition, direction), gameState, levelConfig)) { + newPosition = getNewPosition(newPosition, direction); + } + return newPosition; +}; + +export const handleSokobanMovement = ( + gameState: GameState, + oldPosition: Position, + newPosition: Position, + gridSize: number, +): void => { + const pushedObstacle = gameState.obstacles.find((obstacle) => isPositionEqual(obstacle.position, newPosition)); + if (pushedObstacle) { + const obstacleNewPosition = getNewPosition( + pushedObstacle.position, + getDirectionFromPositions(oldPosition, newPosition), + ); + if (isValidObstaclePush(obstacleNewPosition, gameState, { gridSize })) { + pushedObstacle.position = obstacleNewPosition; + // Check if the pushed obstacle crushes a monster + const crushedMonster = gameState.monsters.find((monster) => + isPositionEqual(monster.position, obstacleNewPosition), + ); + if (crushedMonster) { + gameState.monsters = gameState.monsters.filter((monster) => monster !== crushedMonster); + } + } else { + // If the obstacle can't be pushed, the player doesn't move + newPosition = oldPosition; + } + } +}; + +export const handleBlasterShot = (gameState: GameState, direction: Direction, levelConfig: LevelConfig): void => { + const start = gameState.player.position; + const end = handleSlideMovement(gameState, direction, levelConfig); + const shot: BlasterShot = { + startPosition: start, + endPosition: end, + direction: direction, + shotTimestamp: Date.now(), + duration: BLASTER_SHOT_DURATION * (manhattanDistance(start, end) + 1), + }; + gameState.blasterShots.push(shot); + // Play the blaster sound effect + soundEngine.playBlasterSound(); + + // Check if the shot hits monsters along its path + gameState.monsters = gameState.monsters.filter((monster) => { + const isHit = isMonsterOnBlasterPath(monster.position, start, end, direction); + return !isHit; + }); +}; + +export const isMonsterOnBlasterPath = ( + monsterPos: Position, + start: Position, + end: Position, + direction: Direction, +): boolean => { + switch (direction) { + case Direction.Up: + case Direction.Down: + return ( + monsterPos.x === start.x && + ((direction === Direction.Up && monsterPos.y <= start.y && monsterPos.y >= end.y) || + (direction === Direction.Down && monsterPos.y >= start.y && monsterPos.y <= end.y)) + ); + case Direction.Left: + case Direction.Right: + return ( + monsterPos.y === start.y && + ((direction === Direction.Left && monsterPos.x <= start.x && monsterPos.x >= end.x) || + (direction === Direction.Right && monsterPos.x >= start.x && monsterPos.x <= end.x)) + ); + } +}; diff --git a/js13k2024/game/src/game-states/gameplay/game-logic.ts b/js13k2024/game/src/game-states/gameplay/game-logic.ts index 00dfb4a2..9547eaf2 100644 --- a/js13k2024/game/src/game-states/gameplay/game-logic.ts +++ b/js13k2024/game/src/game-states/gameplay/game-logic.ts @@ -1,13 +1,4 @@ -import { - GameState, - Position, - LevelConfig, - Direction, - BonusType, - ActiveBonus, - BlasterShot, - isActiveBonus, -} from './gameplay-types'; +import { GameState, LevelConfig, Direction, BonusType, isActiveBonus } from './gameplay-types'; import { moveMonsters, checkCollision, @@ -16,26 +7,22 @@ import { isInExplosionRange, } from './monster-logic'; import { generateLevel } from './level-generator'; -import { - BLASTER_SHOT_DURATION, - MOVE_ANIMATION_DURATION, - OBSTACLE_DESTRUCTION_DURATION, -} from './render/animation-utils'; +import { MOVE_ANIMATION_DURATION, OBSTACLE_DESTRUCTION_DURATION } from './render/animation-utils'; import { soundEngine } from '../../sound/sound-engine'; +import { getDirectionFromKey, getNewPosition, isPositionEqual, isPositionOccupied, isValidMove } from './move-utils'; import { - getDirectionFromKey, - getDirectionFromPositions, - getNewPosition, - isPositionEqual, - isPositionOccupied, - isValidMove, - isValidObstaclePush, - manhattanDistance, -} from './move-utils'; + applyBonus, + handleBlasterShot, + handleMonsterBonus, + handleSlideMovement, + handleSokobanMovement, + handleTsunamiEffect, + performTeleportation, +} from './bonus-logic'; export const initializeGame = (level: number): [GameState, LevelConfig] => { const [gameState, config] = generateLevel(level); - return [{ ...gameState, gameEndingState: 'none', tsunamiLevel: 0, isSliding: false, blasterShots: [] }, config]; + return [{ ...gameState, gameEndingState: 'none', tsunamiLevel: 0, blasterShots: [] }, config]; }; export const handleKeyPress = (e: KeyboardEvent, gameState: GameState, levelConfig: LevelConfig): GameState => { @@ -57,7 +44,7 @@ export const doGameUpdate = (direction: Direction, gameState: GameState, levelCo let newPosition = getNewPosition(newGameState.player.position, direction); // Handle Slide bonus - if (newGameState.isSliding) { + if (isActiveBonus(newGameState, BonusType.Slide)) { newPosition = handleSlideMovement(newGameState, direction, levelConfig); } @@ -118,12 +105,8 @@ export const doGameUpdate = (direction: Direction, gameState: GameState, levelCo performTeleportation(newGameState, collectedBonus.position); } - // Spawn monster every 13th step - if (newGameState.monsterSpawnSteps >= 13) { - spawnMonster(newGameState, levelConfig); - newGameState.monsterSpawnSteps = 0; - soundEngine.playMonsterSpawn(); - } + // spawn bonuses, monsters, and do other updates according to current game situation + levelConfig.levelUpdater(newGameState, levelConfig); // Move monsters newGameState.monsters = moveMonsters( @@ -219,86 +202,10 @@ export const doGameUpdate = (direction: Direction, gameState: GameState, levelCo return newGameState; }; -export const applyBonus = (gameState: GameState, bonusType: BonusType) => { - const newActiveBonus: ActiveBonus = { type: bonusType, duration: 13 }; - gameState.activeBonuses.push(newActiveBonus); - - switch (bonusType) { - case BonusType.CapOfInvisibility: - // Logic for Cap of Invisibility is handled in monster movement - break; - case BonusType.ConfusedMonsters: - // Logic for Confused Monsters is handled in monster movement - break; - case BonusType.LandMine: - gameState.landMines.push({ ...gameState.player.position }); - break; - case BonusType.TimeBomb: - gameState.timeBombs.push({ - position: gameState.player.position, - timer: 13, - shakeIntensity: 0, - }); - break; - case BonusType.Crusher: - break; - case BonusType.Builder: - break; - case BonusType.Climber: - break; - case BonusType.Teleport: - // Teleport is handled immediately when collected - break; - case BonusType.Tsunami: - gameState.tsunamiLevel = 1; - break; - case BonusType.Monster: - break; - case BonusType.Slide: - gameState.isSliding = true; - break; - case BonusType.Sokoban: - break; - case BonusType.Blaster: - break; - } -}; - const calculateLevelScore = (gameState: GameState): number => { return 100 - gameState.steps + gameState.monsters.length * 10; }; -const spawnMonster = (gameState: GameState, { gridSize }: LevelConfig) => { - let monsterPosition; - do { - monsterPosition = generateRandomPosition(gridSize, gridSize); - } while ( - isPositionEqual(monsterPosition, gameState.player.position) || - isPositionEqual(monsterPosition, gameState.goal) || - isPositionOccupied( - monsterPosition, - gameState.obstacles.filter((obstacle) => !obstacle.isDestroying).map(({ position }) => position), - ) || - isPositionOccupied( - monsterPosition, - gameState.monsters.map((m) => m.position), - ) - ); - gameState.monsters.push({ - position: monsterPosition, - previousPosition: monsterPosition, - moveTimestamp: Date.now(), - path: [], - seed: Math.random(), - isConfused: false, - }); -}; - -const generateRandomPosition = (width: number, height: number): Position => ({ - x: Math.floor(Math.random() * width), - y: Math.floor(Math.random() * height), -}); - export const isGameEnding = (gameState: GameState): boolean => { return gameState.gameEndingState !== 'none'; }; @@ -312,122 +219,3 @@ export const startLevelCompleteAnimation = (gameState: GameState): void => { gameState.player.isVictorious = true; soundEngine.playLevelComplete(); }; - -const performTeleportation = (gameState: GameState, teleportPoint: Position): void => { - const destinationPoint = gameState.bonuses.find( - (bonus) => bonus.type === BonusType.Teleport && !isPositionEqual(bonus.position, teleportPoint), - ); - if (destinationPoint) { - gameState.player.previousPosition = gameState.player.position; - gameState.player.position = destinationPoint.position; - gameState.player.teleportTimestamp = Date.now(); - soundEngine.playTeleport(); - } -}; - -const handleTsunamiEffect = (gameState: GameState): void => { - gameState.tsunamiLevel++; - if (gameState.tsunamiLevel >= 13) { - if (!isActiveBonus(gameState, BonusType.Climber)) { - gameState.gameEndingState = 'gameOver'; - startGameOverAnimation(gameState); - } - gameState.monsters = []; - gameState.tsunamiLevel = 0; - } -}; - -const handleMonsterBonus = (gameState: GameState): void => { - // Check for collisions with monsters (now players) - const collidedMonster = gameState.monsters.find((monster) => - isPositionEqual(gameState.player.position, monster.position), - ); - if (collidedMonster) { - gameState.monsters = gameState.monsters.filter((monster) => monster !== collidedMonster); - if (gameState.monsters.length === 0) { - gameState.gameEndingState = 'gameOver'; - startGameOverAnimation(gameState); - } - } -}; - -const handleSlideMovement = (gameState: GameState, direction: Direction, levelConfig: LevelConfig): Position => { - let newPosition = gameState.player.position; - while (isValidMove(getNewPosition(newPosition, direction), gameState, levelConfig)) { - newPosition = getNewPosition(newPosition, direction); - } - return newPosition; -}; - -const handleSokobanMovement = ( - gameState: GameState, - oldPosition: Position, - newPosition: Position, - gridSize: number, -): void => { - const pushedObstacle = gameState.obstacles.find((obstacle) => isPositionEqual(obstacle.position, newPosition)); - if (pushedObstacle) { - const obstacleNewPosition = getNewPosition( - pushedObstacle.position, - getDirectionFromPositions(oldPosition, newPosition), - ); - if (isValidObstaclePush(obstacleNewPosition, gameState, { gridSize })) { - pushedObstacle.position = obstacleNewPosition; - // Check if the pushed obstacle crushes a monster - const crushedMonster = gameState.monsters.find((monster) => - isPositionEqual(monster.position, obstacleNewPosition), - ); - if (crushedMonster) { - gameState.monsters = gameState.monsters.filter((monster) => monster !== crushedMonster); - } - } else { - // If the obstacle can't be pushed, the player doesn't move - newPosition = oldPosition; - } - } -}; - -const handleBlasterShot = (gameState: GameState, direction: Direction, levelConfig: LevelConfig): void => { - const start = gameState.player.position; - const end = handleSlideMovement(gameState, direction, levelConfig); - const shot: BlasterShot = { - startPosition: start, - endPosition: end, - direction: direction, - shotTimestamp: Date.now(), - duration: BLASTER_SHOT_DURATION * (manhattanDistance(start, end) + 1), - }; - gameState.blasterShots.push(shot); - // Play the blaster sound effect - soundEngine.playBlasterSound(); - - // Check if the shot hits monsters along its path - gameState.monsters = gameState.monsters.filter((monster) => { - const isHit = isMonsterOnBlasterPath(monster.position, start, end, direction); - return !isHit; - }); -}; - -const isMonsterOnBlasterPath = ( - monsterPos: Position, - start: Position, - end: Position, - direction: Direction, -): boolean => { - switch (direction) { - case Direction.Up: - case Direction.Down: - return ( - monsterPos.x === start.x && - ((direction === Direction.Up && monsterPos.y <= start.y && monsterPos.y >= end.y) || - (direction === Direction.Down && monsterPos.y >= start.y && monsterPos.y <= end.y)) - ); - case Direction.Left: - case Direction.Right: - return ( - monsterPos.y === start.y && - ((direction === Direction.Left && monsterPos.x <= start.x && monsterPos.x >= end.x) || - (direction === Direction.Right && monsterPos.x >= start.x && monsterPos.x <= end.x)) - ); - } -}; diff --git a/js13k2024/game/src/game-states/gameplay/game-render.ts b/js13k2024/game/src/game-states/gameplay/game-render.ts index d3ccfdd3..cba05e20 100644 --- a/js13k2024/game/src/game-states/gameplay/game-render.ts +++ b/js13k2024/game/src/game-states/gameplay/game-render.ts @@ -49,7 +49,7 @@ export const drawGameState = ( drawElectricalDischarges(ctx, gridSize, gameState.monsterSpawnSteps, gameState.player.moveTimestamp, cellSize); // Draw slide movement trail - if (gameState.isSliding) { + if (isActiveBonus(gameState, BonusType.Slide)) { drawSlideTrail(ctx, gameState.player.previousPosition, gameState.player.position); } diff --git a/js13k2024/game/src/game-states/gameplay/gameplay-types.ts b/js13k2024/game/src/game-states/gameplay/gameplay-types.ts index 87f93bd1..e5106c14 100644 --- a/js13k2024/game/src/game-states/gameplay/gameplay-types.ts +++ b/js13k2024/game/src/game-states/gameplay/gameplay-types.ts @@ -66,7 +66,6 @@ export interface GameState { score: number; gameEndingState: GameEndingState; tsunamiLevel: number; - isSliding: boolean; blasterShots: BlasterShot[]; } @@ -98,10 +97,12 @@ export interface LevelConfig { gridSize: number; cellSize: number; initialMonsterCount: number; + monsterSpawnSectors: Position[]; obstacleCount: number; initialBonusCount: number; levelName: string; levelStory: string; + levelUpdater: (state: GameState, levelConfig: LevelConfig) => void; } export interface Score { diff --git a/js13k2024/game/src/game-states/gameplay/level-generator.ts b/js13k2024/game/src/game-states/gameplay/level-generator.ts index 8d8bfa2a..3ea3cdd7 100644 --- a/js13k2024/game/src/game-states/gameplay/level-generator.ts +++ b/js13k2024/game/src/game-states/gameplay/level-generator.ts @@ -1,4 +1,5 @@ import { GameState, LevelConfig, Position, Monster, Player, BonusType, Obstacle } from './gameplay-types'; +import { simpleLevelUpdater } from './level-updater'; import { generateLevel as generateLevel1 } from './levels/01-the-first-step'; import { generateLevel as generateLevel2 } from './levels/02-now-you-see-me'; @@ -9,8 +10,8 @@ import { generateLevel as generateLevel6 } from './levels/06-minesweepers-reveng import { generateLevel as generateLevel7 } from './levels/07-monsters-mayhem'; import { generateLevel as generateLevel8 } from './levels/08-tunnel-vision'; import { generateLevel as generateLevel9 } from './levels/09-ghost-bomber'; -import { generateLevel as generateLevel10 } from './levels/10-crush-confuse-conquer'; -import { generateLevel as generateLevel11 } from './levels/11-the-triple-threat'; +import { generateLevel as generateLevel10 } from './levels/10-surf-and-climb'; +import { generateLevel as generateLevel11 } from './levels/11-slide-and-blast'; import { generateLevel as generateLevel12 } from './levels/12-the-gauntlet'; import { generateLevel as generateLevel13 } from './levels/13-the-final-countdown'; @@ -60,20 +61,26 @@ const generateBaseState = (): GameState => ({ score: 0, gameEndingState: 'none', tsunamiLevel: 0, - isSliding: false, blasterShots: [], }); const CELL_SIZE = 40; -const generateBaseConfig = (gridSize: number, levelName: string, levelStory: string): LevelConfig => ({ +const generateBaseConfig = ( + gridSize: number, + levelName: string, + levelStory: string, + levelUpdater = simpleLevelUpdater, +): LevelConfig => ({ gridSize, cellSize: CELL_SIZE, initialMonsterCount: 0, + monsterSpawnSectors: [], obstacleCount: 0, initialBonusCount: 0, levelName, levelStory, + levelUpdater, }); const levels: { [key: number]: () => [GameState, LevelConfig, string] } = { diff --git a/js13k2024/game/src/game-states/gameplay/level-updater.ts b/js13k2024/game/src/game-states/gameplay/level-updater.ts new file mode 100644 index 00000000..c85be74c --- /dev/null +++ b/js13k2024/game/src/game-states/gameplay/level-updater.ts @@ -0,0 +1,14 @@ +import { soundEngine } from '../../sound/sound-engine'; +import { GameState, LevelConfig } from './gameplay-types'; +import { spawnMonster } from './monster-spawn'; + +export function simpleLevelUpdater(gameState: GameState, levelConfig: LevelConfig) { + if (gameState.monsterSpawnSteps >= 13) { + const newMonster = spawnMonster(gameState, levelConfig); + if (newMonster) { + gameState.monsters.push(newMonster); + gameState.monsterSpawnSteps = 0; + soundEngine.playMonsterSpawn(); + } + } +} diff --git a/js13k2024/game/src/game-states/gameplay/levels/04-crush-and-rush.ts b/js13k2024/game/src/game-states/gameplay/levels/04-crush-and-rush.ts index 6ceed617..c283b15a 100644 --- a/js13k2024/game/src/game-states/gameplay/levels/04-crush-and-rush.ts +++ b/js13k2024/game/src/game-states/gameplay/levels/04-crush-and-rush.ts @@ -12,7 +12,7 @@ import { export const generateLevel = (): [GameState, LevelConfig, string] => { const state = generateBaseState(); const config = generateBaseConfig(9, 'Crush and Rush', 'Clear the path with your crushing power!'); - + state.player = createPlayer(0, 4); state.goal = createPosition(8, 4); state.monsters = [createMonster(4, 4)]; @@ -25,11 +25,11 @@ export const generateLevel = (): [GameState, LevelConfig, string] => { createObstacle(7, 5), createObstacle(8, 5), ]; - + for (let i = 2; i < 7; i++) { state.obstacles.push(createObstacle(i, 3)); state.obstacles.push(createObstacle(i - 1, 5)); } - + return [state, config, config.levelStory]; -}; \ No newline at end of file +}; diff --git a/js13k2024/game/src/game-states/gameplay/levels/05-tick-tock-boom.ts b/js13k2024/game/src/game-states/gameplay/levels/05-tick-tock-boom.ts index 2e8d3e03..f5cf8113 100644 --- a/js13k2024/game/src/game-states/gameplay/levels/05-tick-tock-boom.ts +++ b/js13k2024/game/src/game-states/gameplay/levels/05-tick-tock-boom.ts @@ -12,11 +12,11 @@ import { export const generateLevel = (): [GameState, LevelConfig, string] => { const state = generateBaseState(); const config = generateBaseConfig(10, 'Tick Tock Boom', 'Time your bomb perfectly to clear the way!'); - + state.player = createPlayer(0, 5); state.goal = createPosition(9, 5); state.monsters = [createMonster(3, 5)]; - state.bonuses = [createBonus(9, 3, BonusType.TimeBomb)]; + state.bonuses = [createBonus(8, 3, BonusType.TimeBomb)]; state.obstacles = [ // tunnel createObstacle(9, 4), @@ -25,18 +25,22 @@ export const generateLevel = (): [GameState, LevelConfig, string] => { createObstacle(8, 6), createObstacle(9, 6), // run around this and time the bomb + createObstacle(2, 1), + createObstacle(3, 1), createObstacle(4, 1), createObstacle(5, 1), createObstacle(6, 1), createObstacle(7, 1), createObstacle(8, 1), ]; - + for (let i = 2; i < 8; i++) { state.obstacles.push(createObstacle(i, 4)); state.obstacles.push(createObstacle(i, 6)); state.obstacles.push(createObstacle(i - 2, 8)); } - + + config.monsterSpawnSectors = [createPosition(1, 9)]; + return [state, config, config.levelStory]; -}; \ No newline at end of file +}; diff --git a/js13k2024/game/src/game-states/gameplay/levels/07-monsters-mayhem.ts b/js13k2024/game/src/game-states/gameplay/levels/07-monsters-mayhem.ts index 8e86f540..38d437d1 100644 --- a/js13k2024/game/src/game-states/gameplay/levels/07-monsters-mayhem.ts +++ b/js13k2024/game/src/game-states/gameplay/levels/07-monsters-mayhem.ts @@ -12,22 +12,16 @@ import { export const generateLevel = (): [GameState, LevelConfig, string] => { const state = generateBaseState(); const config = generateBaseConfig(11, "Monsters' Mayhem", 'Confuse them all and make your escape!'); - + state.player = createPlayer(0, 5); state.goal = createPosition(10, 5); - state.monsters = [ - createMonster(3, 4), - createMonster(3, 6), - createMonster(6, 4), - createMonster(6, 6), - createMonster(9, 5), - ]; + state.monsters = [createMonster(3, 4), createMonster(3, 6), createMonster(6, 4), createMonster(6, 6)]; state.bonuses = [createBonus(2, 5, BonusType.ConfusedMonsters)]; - + for (let i = 4; i < 8; i++) { state.obstacles.push(createObstacle(i, 3)); state.obstacles.push(createObstacle(i, 7)); } - + return [state, config, config.levelStory]; -}; \ No newline at end of file +}; diff --git a/js13k2024/game/src/game-states/gameplay/levels/08-tunnel-vision.ts b/js13k2024/game/src/game-states/gameplay/levels/08-tunnel-vision.ts index d678546a..5e2cec52 100644 --- a/js13k2024/game/src/game-states/gameplay/levels/08-tunnel-vision.ts +++ b/js13k2024/game/src/game-states/gameplay/levels/08-tunnel-vision.ts @@ -1,7 +1,6 @@ import { GameState, LevelConfig, BonusType } from '../gameplay-types'; import { createPosition, - createMonster, createObstacle, createPlayer, createBonus, @@ -12,21 +11,39 @@ import { export const generateLevel = (): [GameState, LevelConfig, string] => { const state = generateBaseState(); const config = generateBaseConfig(12, 'Tunnel Vision', 'Build a path and set a trap!'); - + state.player = createPlayer(0, 6); state.goal = createPosition(11, 6); - state.monsters = [createMonster(3, 10), createMonster(8, 8)]; + state.monsters = []; // Remove initial monsters state.bonuses = [ createBonus(2, 6, BonusType.Builder), - createBonus(2, 2, BonusType.LandMine) + createBonus(5, 6, BonusType.LandMine), + createBonus(5, 5, BonusType.Sokoban), ]; - + + // Create a partial tunnel, leaving space for the player to build for (let i = 3; i < 10; i++) { - if (i !== 6) { + if (i !== 5 && i !== 8) { state.obstacles.push(createObstacle(i, 5)); state.obstacles.push(createObstacle(i, 7)); } } - - return [state, config, config.levelStory]; -}; \ No newline at end of file + + // Add some obstacles to create a more interesting layout + state.obstacles.push(createObstacle(1, 4)); + state.obstacles.push(createObstacle(1, 8)); + state.obstacles.push(createObstacle(10, 4)); + state.obstacles.push(createObstacle(10, 8)); + + // Add obstacles near the goal to encourage strategic building + state.obstacles.push(createObstacle(11, 5)); + state.obstacles.push(createObstacle(11, 7)); + + config.monsterSpawnSectors = [createPosition(0, 0)]; + + return [ + state, + config, + 'Use the Builder bonus to create a path and set up a trap with the Land Mine. Be strategic about where you place your obstacles and mine to catch the spawning monsters!', + ]; +}; diff --git a/js13k2024/game/src/game-states/gameplay/levels/09-ghost-bomber.ts b/js13k2024/game/src/game-states/gameplay/levels/09-ghost-bomber.ts index de7fad1f..3f903a52 100644 --- a/js13k2024/game/src/game-states/gameplay/levels/09-ghost-bomber.ts +++ b/js13k2024/game/src/game-states/gameplay/levels/09-ghost-bomber.ts @@ -12,21 +12,42 @@ import { export const generateLevel = (): [GameState, LevelConfig, string] => { const state = generateBaseState(); const config = generateBaseConfig(12, 'Ghost Bomber', 'Vanish, plant, and detonate!'); - + state.player = createPlayer(0, 6); state.goal = createPosition(11, 6); - state.monsters = [createMonster(3, 6), createMonster(7, 6), createMonster(10, 6)]; + state.monsters = [createMonster(3, 1), createMonster(7, 11), createMonster(10, 3)]; // Monsters are now further away from the player's starting position state.bonuses = [ createBonus(2, 6, BonusType.CapOfInvisibility), - createBonus(5, 6, BonusType.TimeBomb) - ]; - - for (let i = 4; i < 11; i++) { - if (i !== 5 && i !== 8) { - state.obstacles.push(createObstacle(i, 5)); - state.obstacles.push(createObstacle(i, 7)); + createBonus(9, 6, BonusType.TimeBomb), + createBonus(7, 4, BonusType.CapOfInvisibility), + ]; // Invisibility is closer to start, bomb is closer to goal + + // Create a maze-like structure + for (let i = 2; i < 11; i += 2) { + for (let j = 1; j < 12; j += 2) { + if (!(i === 10 && j === 5) && !(i === 10 && j === 7)) { + // Leave space for goal + state.obstacles.push(createObstacle(i, j)); + } } } - - return [state, config, config.levelStory]; -}; \ No newline at end of file + + // Surround the goal with obstacles + state.obstacles.push(createObstacle(11, 5)); + state.obstacles.push(createObstacle(10, 6)); + state.obstacles.push(createObstacle(11, 7)); + state.obstacles.push(createObstacle(10, 8)); + + // Add some strategic obstacles + state.obstacles.push(createObstacle(1, 3)); + state.obstacles.push(createObstacle(1, 9)); + state.obstacles.push(createObstacle(5, 6)); + state.obstacles.push(createObstacle(8, 4)); + state.obstacles.push(createObstacle(8, 8)); + + return [ + state, + config, + 'Use your invisibility wisely to sneak past the monsters and reach the time bomb. Then, strategically place and detonate the bomb to clear a path to the goal!', + ]; +}; diff --git a/js13k2024/game/src/game-states/gameplay/levels/10-crush-confuse-conquer.ts b/js13k2024/game/src/game-states/gameplay/levels/10-crush-confuse-conquer.ts deleted file mode 100644 index 4989923f..00000000 --- a/js13k2024/game/src/game-states/gameplay/levels/10-crush-confuse-conquer.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { GameState, LevelConfig, BonusType } from '../gameplay-types'; -import { - createPosition, - createMonster, - createObstacle, - createPlayer, - createBonus, - generateBaseState, - generateBaseConfig, -} from '../level-generator'; - -export const generateLevel = (): [GameState, LevelConfig, string] => { - const state = generateBaseState(); - const config = generateBaseConfig(13, 'Crush, Confuse, Conquer', 'A symphony of chaos!'); - - state.player = createPlayer(0, 6); - state.goal = createPosition(12, 6); - state.monsters = [createMonster(4, 6), createMonster(8, 6), createMonster(11, 6)]; - state.bonuses = [ - createBonus(2, 6, BonusType.Crusher), - createBonus(6, 6, BonusType.ConfusedMonsters) - ]; - - for (let i = 3; i < 12; i++) { - if (i !== 6 && i !== 9) { - state.obstacles.push(createObstacle(i, 5)); - state.obstacles.push(createObstacle(i, 7)); - } - } - - return [state, config, config.levelStory]; -}; \ No newline at end of file diff --git a/js13k2024/game/src/game-states/gameplay/levels/10-surf-and-climb.ts b/js13k2024/game/src/game-states/gameplay/levels/10-surf-and-climb.ts new file mode 100644 index 00000000..d8875c8c --- /dev/null +++ b/js13k2024/game/src/game-states/gameplay/levels/10-surf-and-climb.ts @@ -0,0 +1,56 @@ +import { GameState, LevelConfig, BonusType } from '../gameplay-types'; +import { + createPosition, + createMonster, + createObstacle, + createPlayer, + createBonus, + generateBaseState, + generateBaseConfig, +} from '../level-generator'; + +export const generateLevel = (): [GameState, LevelConfig, string] => { + const state = generateBaseState(); + const config = generateBaseConfig(13, 'Surf and Climb', 'Ride the wave and climb to safety!'); + + state.player = createPlayer(0, 6); + state.goal = createPosition(12, 6); + state.monsters = [ + createMonster(4, 6), + createMonster(8, 6), + createMonster(11, 6), + createMonster(6, 2), + createMonster(6, 10), + ]; + state.bonuses = [createBonus(2, 6, BonusType.Tsunami), createBonus(4, 4, BonusType.Climber)]; + + // Create a central path with obstacles on both sides + for (let i = 1; i < 12; i++) { + if (i !== 2 && i !== 10) { + // Leave space for bonuses + state.obstacles.push(createObstacle(i, 5)); + state.obstacles.push(createObstacle(i, 7)); + } + } + + // Create "islands" of obstacles that can be climbed + state.obstacles.push(createObstacle(3, 6)); + state.obstacles.push(createObstacle(6, 6)); + state.obstacles.push(createObstacle(9, 6)); + + // Add some obstacles near the edges + for (let i = 0; i < 13; i += 3) { + state.obstacles.push(createObstacle(i, 0)); + state.obstacles.push(createObstacle(i, 12)); + } + + // Add obstacles near the goal to make it challenging + state.obstacles.push(createObstacle(11, 5)); + state.obstacles.push(createObstacle(11, 7)); + + return [ + state, + config, + 'Collect the Tsunami bonus first, then make your way to the Climber bonus. Use the Climber to get on top of an obstacle, then activate the Tsunami to wash away the monsters. Time it right to reach the goal!', + ]; +}; diff --git a/js13k2024/game/src/game-states/gameplay/levels/11-slide-and-blast.ts b/js13k2024/game/src/game-states/gameplay/levels/11-slide-and-blast.ts new file mode 100644 index 00000000..e7182d5c --- /dev/null +++ b/js13k2024/game/src/game-states/gameplay/levels/11-slide-and-blast.ts @@ -0,0 +1,56 @@ +import { GameState, LevelConfig, BonusType } from '../gameplay-types'; +import { + createPosition, + createMonster, + createObstacle, + createPlayer, + createBonus, + generateBaseState, + generateBaseConfig, +} from '../level-generator'; + +export const generateLevel = (): [GameState, LevelConfig, string] => { + const state = generateBaseState(); + const config = generateBaseConfig(14, 'Slide and Blast', 'Slide through danger and blast your way to victory!'); + + state.player = createPlayer(0, 0); + state.goal = createPosition(13, 13); + state.monsters = [ + createMonster(3, 3), + createMonster(10, 10), + createMonster(6, 7), + createMonster(7, 6), + createMonster(13, 0), + ]; + state.bonuses = [createBonus(0, 2, BonusType.Blaster), createBonus(0, 13, BonusType.Slide)]; + + // Create a maze-like structure + for (let i = 2; i < 12; i += 3) { + for (let j = 0; j < 14; j++) { + if (j !== 7) { + // Leave a gap in the middle + state.obstacles.push(createObstacle(i, j)); + } + } + } + + // Create a path that requires sliding + state.obstacles.push(createObstacle(12, 1)); + state.obstacles.push(createObstacle(12, 2)); + state.obstacles.push(createObstacle(11, 1)); + + // Obstacles useful for sliding + state.obstacles.push(createObstacle(0, 6)); + + // Add some strategic single obstacles + state.obstacles.push(createObstacle(1, 1)); + state.obstacles.push(createObstacle(4, 4)); + state.obstacles.push(createObstacle(9, 9)); + state.obstacles.push(createObstacle(12, 10)); + + return [ + state, + config, + 'Navigate to the Slide bonus to quickly traverse the level. Then, collect the Blaster to destroy the wall blocking your path to the goal. Watch out for monsters and use your bonuses strategically!', + ]; +}; diff --git a/js13k2024/game/src/game-states/gameplay/levels/11-the-triple-threat.ts b/js13k2024/game/src/game-states/gameplay/levels/11-the-triple-threat.ts deleted file mode 100644 index 3b4078cb..00000000 --- a/js13k2024/game/src/game-states/gameplay/levels/11-the-triple-threat.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { GameState, LevelConfig, BonusType } from '../gameplay-types'; -import { - createPosition, - createMonster, - createObstacle, - createPlayer, - createBonus, - generateBaseState, - generateBaseConfig, -} from '../level-generator'; - -export const generateLevel = (): [GameState, LevelConfig, string] => { - const state = generateBaseState(); - const config = generateBaseConfig(14, 'The Triple Threat', 'Build, Bomb, and Vanish!'); - - state.player = createPlayer(0, 7); - state.goal = createPosition(13, 7); - state.monsters = [createMonster(3, 7), createMonster(6, 7), createMonster(9, 7), createMonster(12, 7)]; - state.bonuses = [ - createBonus(2, 7, BonusType.Builder), - createBonus(5, 7, BonusType.TimeBomb), - createBonus(8, 7, BonusType.CapOfInvisibility), - ]; - - for (let i = 4; i < 13; i++) { - if (i !== 5 && i !== 8 && i !== 11) { - state.obstacles.push(createObstacle(i, 6)); - state.obstacles.push(createObstacle(i, 8)); - } - } - - return [state, config, config.levelStory]; -}; \ No newline at end of file diff --git a/js13k2024/game/src/game-states/gameplay/levels/12-the-gauntlet.ts b/js13k2024/game/src/game-states/gameplay/levels/12-the-gauntlet.ts index 62d1a767..8f1a3121 100644 --- a/js13k2024/game/src/game-states/gameplay/levels/12-the-gauntlet.ts +++ b/js13k2024/game/src/game-states/gameplay/levels/12-the-gauntlet.ts @@ -11,40 +11,59 @@ import { export const generateLevel = (): [GameState, LevelConfig, string] => { const state = generateBaseState(); - const config = generateBaseConfig(15, 'The Gauntlet', "Use everything you've learned!"); + const config = generateBaseConfig(15, 'The Gauntlet', 'Master all your skills to conquer the final challenge!'); state.player = createPlayer(0, 7); state.goal = createPosition(14, 7); state.monsters = [ - createMonster(3, 7), - createMonster(6, 7), - createMonster(9, 7), - createMonster(12, 7), - createMonster(7, 3), - createMonster(7, 11), + createMonster(5, 2), + createMonster(5, 12), + createMonster(10, 4), + createMonster(10, 10), + createMonster(14, 0), + createMonster(14, 14) ]; state.bonuses = [ - createBonus(2, 7, BonusType.LandMine), - createBonus(2, 8, BonusType.CapOfInvisibility), - createBonus(5, 7, BonusType.Crusher), - createBonus(8, 7, BonusType.ConfusedMonsters), - createBonus(1, 6, BonusType.Builder), - createBonus(2, 6, BonusType.TimeBomb), + createBonus(2, 7, BonusType.CapOfInvisibility), + createBonus(4, 7, BonusType.Crusher), + createBonus(7, 3, BonusType.TimeBomb), + createBonus(7, 11, BonusType.LandMine), + createBonus(10, 7, BonusType.Blaster), + createBonus(12, 7, BonusType.Climber) ]; - for (let i = 4; i < 14; i++) { - if (i !== 5 && i !== 8 && i !== 11) { + // Create a central path with obstacles + for (let i = 1; i < 14; i++) { + if (i % 3 !== 1) { // Leave gaps for bonuses and movement state.obstacles.push(createObstacle(i, 6)); state.obstacles.push(createObstacle(i, 8)); } } - for (let i = 4; i < 11; i++) { - if (i !== 7) { - state.obstacles.push(createObstacle(6, i)); - state.obstacles.push(createObstacle(8, i)); + // Create vertical barriers + for (let i = 0; i < 15; i += 3) { + if (i !== 6 && i !== 9) { // Leave central area open + for (let j = 0; j < 15; j++) { + if (j !== 7) { // Leave central path open + state.obstacles.push(createObstacle(i, j)); + } + } } } - return [state, config, config.levelStory]; + // Add some strategic single obstacles + state.obstacles.push(createObstacle(3, 5)); + state.obstacles.push(createObstacle(3, 9)); + state.obstacles.push(createObstacle(7, 5)); + state.obstacles.push(createObstacle(7, 9)); + state.obstacles.push(createObstacle(11, 5)); + state.obstacles.push(createObstacle(11, 9)); + + // Create a challenging area near the goal + state.obstacles.push(createObstacle(13, 6)); + state.obstacles.push(createObstacle(13, 8)); + state.obstacles.push(createObstacle(14, 6)); + state.obstacles.push(createObstacle(14, 8)); + + return [state, config, "This is your final test! Use all the skills you've learned to navigate through the gauntlet. Collect bonuses strategically, avoid or eliminate monsters, and make your way to the goal. Good luck!"]; }; \ No newline at end of file diff --git a/js13k2024/game/src/game-states/gameplay/levels/13-the-final-countdown.ts b/js13k2024/game/src/game-states/gameplay/levels/13-the-final-countdown.ts index d4766b61..0316a060 100644 --- a/js13k2024/game/src/game-states/gameplay/levels/13-the-final-countdown.ts +++ b/js13k2024/game/src/game-states/gameplay/levels/13-the-final-countdown.ts @@ -1,4 +1,4 @@ -import { GameState, LevelConfig, BonusType } from '../gameplay-types'; +import { GameState, LevelConfig, BonusType, Position } from '../gameplay-types'; import { createPosition, createMonster, @@ -9,45 +9,103 @@ import { generateBaseConfig, } from '../level-generator'; +const GRID_SIZE = 16; +const MIN_DISTANCE_FROM_PLAYER = 5; + +function getRandomPosition(): Position { + return { + x: Math.floor(Math.random() * GRID_SIZE), + y: Math.floor(Math.random() * GRID_SIZE), + }; +} + +function distanceBetween(pos1: Position, pos2: Position): number { + return Math.abs(pos1.x - pos2.x) + Math.abs(pos1.y - pos2.y); +} + +function isPositionValid(pos: Position, state: GameState): boolean { + return ( + !state.obstacles.some((obs) => obs.position.x === pos.x && obs.position.y === pos.y) && + !state.monsters.some((monster) => monster.position.x === pos.x && monster.position.y === pos.y) && + !state.bonuses.some((bonus) => bonus.position.x === pos.x && bonus.position.y === pos.y) && + pos.x !== state.player.position.x && + pos.y !== state.player.position.y && + pos.x !== state.goal.x && + pos.y !== state.goal.y + ); +} + +function spawnObstacles(state: GameState, count: number): void { + for (let i = 0; i < count; i++) { + let pos; + do { + pos = getRandomPosition(); + } while (!isPositionValid(pos, state)); + state.obstacles.push(createObstacle(pos.x, pos.y)); + } +} + +function spawnBonus(state: GameState): void { + const bonusTypes = Object.values(BonusType).filter((v) => typeof v === 'number') as BonusType[]; + const randomBonusType = bonusTypes[Math.floor(Math.random() * bonusTypes.length)]; + let pos; + do { + pos = getRandomPosition(); + } while (!isPositionValid(pos, state) || distanceBetween(pos, state.player.position) < MIN_DISTANCE_FROM_PLAYER); + state.bonuses.push(createBonus(pos.x, pos.y, randomBonusType)); +} + export const generateLevel = (): [GameState, LevelConfig, string] => { const state = generateBaseState(); - const config = generateBaseConfig(16, 'The Final Countdown', 'Can you outsmart them all?'); - - state.player = createPlayer(0, 8); - state.goal = createPosition(15, 8); - state.monsters = [ - createMonster(5, 8), - createMonster(6, 8), - createMonster(9, 8), - createMonster(12, 8), - createMonster(8, 3), - createMonster(8, 13), - createMonster(15, 3), - createMonster(15, 13), + const config = generateBaseConfig( + GRID_SIZE, + 'The Final Countdown', + 'Survive and thrive in this ever-changing challenge!', + updateDynamicLevel, + ); + + state.player = createPlayer(0, 0); + state.goal = createPosition(GRID_SIZE - 1, GRID_SIZE - 1); + state.obstacles = [ + createObstacle(GRID_SIZE - 2, GRID_SIZE - 2), + createObstacle(GRID_SIZE - 1, GRID_SIZE - 2), + createObstacle(GRID_SIZE - 2, GRID_SIZE - 1), ]; - state.bonuses = [ - createBonus(2, 8, BonusType.CapOfInvisibility), - createBonus(5, 8, BonusType.TimeBomb), - createBonus(8, 8, BonusType.LandMine), - createBonus(11, 1, BonusType.Crusher), - createBonus(14, 14, BonusType.ConfusedMonsters), + + // Initial setup + spawnObstacles(state, 50); + for (let i = 0; i < 3; i++) { + spawnBonus(state); + } + + return [ + state, + config, + 'Welcome to the ultimate challenge! The level will dynamically evolve as you play. Use your skills wisely, collect bonuses, and make your way to the goal while avoiding the increasing dangers. Good luck!', ]; - - for (let i = 4; i < 15; i++) { - if (i !== 5 && i !== 8 && i !== 11 && i !== 14) { - state.obstacles.push(createObstacle(i, 7)); - state.obstacles.push(createObstacle(i, 9)); - } +}; + +// This function should be called from the main game loop to update the level +const updateDynamicLevel = (state: GameState): void => { + // Spawn a new monster every 13 steps + if (state.steps % 13 === 0) { + let pos; + do { + pos = getRandomPosition(); + } while (!isPositionValid(pos, state) || distanceBetween(pos, state.player.position) < MIN_DISTANCE_FROM_PLAYER); + state.monsters.push(createMonster(pos.x, pos.y)); + + // Spawn a new bonus alongside the monster + spawnBonus(state); + } + + // Occasionally add new obstacles + if (state.steps % 50 === 0) { + spawnObstacles(state, 1); } - - for (let i = 4; i < 13; i++) { - if (i !== 8) { - state.obstacles.push(createObstacle(7, i)); - state.obstacles.push(createObstacle(9, i)); - } + + // Ensure there are always at least 3 bonuses on the board + while (state.bonuses.length < 3) { + spawnBonus(state); } - - state.obstacles.push(createObstacle(14, 8)); - - return [state, config, config.levelStory]; -}; \ No newline at end of file +}; diff --git a/js13k2024/game/src/game-states/gameplay/monster-spawn.ts b/js13k2024/game/src/game-states/gameplay/monster-spawn.ts new file mode 100644 index 00000000..a7015fca --- /dev/null +++ b/js13k2024/game/src/game-states/gameplay/monster-spawn.ts @@ -0,0 +1,78 @@ +import { GameState, Monster, Position, BonusType, LevelConfig } from './gameplay-types'; +import { isPositionOccupied, isPositionEqual, manhattanDistance } from './move-utils'; + +const MIN_SPAWN_DISTANCE_FROM_PLAYER = 10; +const MIN_SPAWN_DISTANCE_FROM_BONUS = 10; + +export const spawnMonster = (gameState: GameState, { gridSize, monsterSpawnSectors }: LevelConfig): Monster | null => { + let attempts = 0; + const maxAttempts = 50; + + while (attempts < maxAttempts) { + const position = + monsterSpawnSectors.length > 0 + ? monsterSpawnSectors[Math.round(monsterSpawnSectors.length * Math.random())] + : { + x: Math.floor(Math.random() * gridSize), + y: Math.floor(Math.random() * gridSize), + }; + + if (isValidSpawnPosition(position, gameState, gridSize)) { + return { + position, + previousPosition: position, + moveTimestamp: Date.now(), + path: [], + seed: Math.random(), + isConfused: false, + }; + } + + attempts++; + } + + return null; // Failed to spawn a monster after max attempts +}; + +const isValidSpawnPosition = (position: Position, gameState: GameState, gridSize: number): boolean => { + if (position.x < 0 || position.y < 0 || position.x >= gridSize || position.y >= gridSize) { + return false; + } + + // Check if the position is occupied by obstacles, other monsters, or bonuses + if ( + isPositionOccupied( + position, + gameState.obstacles.map((o) => o.position), + ) || + isPositionOccupied( + position, + gameState.monsters.map((m) => m.position), + ) || + isPositionOccupied( + position, + gameState.bonuses.map((b) => b.position), + ) || + isPositionEqual(position, gameState.goal) + ) { + return false; + } + + // Check distance from player + if (manhattanDistance(position, gameState.player.position) < MIN_SPAWN_DISTANCE_FROM_PLAYER) { + return false; + } + + // Check distance from critical bonuses + const criticalBonusTypes = [BonusType.TimeBomb, BonusType.LandMine, BonusType.Crusher, BonusType.Blaster]; + for (const bonus of gameState.bonuses) { + if ( + criticalBonusTypes.includes(bonus.type) && + manhattanDistance(position, bonus.position) < MIN_SPAWN_DISTANCE_FROM_BONUS + ) { + return false; + } + } + + return true; +}; diff --git a/js13k2024/game/src/game-states/gameplay/move-utils.ts b/js13k2024/game/src/game-states/gameplay/move-utils.ts index 93ac9932..464bc205 100644 --- a/js13k2024/game/src/game-states/gameplay/move-utils.ts +++ b/js13k2024/game/src/game-states/gameplay/move-utils.ts @@ -130,7 +130,7 @@ export const getValidMoves = ( const { player } = gameState; const directions = [Direction.Up, Direction.Down, Direction.Left, Direction.Right]; - if (gameState.isSliding) { + if (isActiveBonus(gameState, BonusType.Slide)) { // For sliding, we need to check the entire path until an obstacle or grid edge return directions .map((direction) => { diff --git a/js13k2024/game/src/game-states/gameplay/render/bonus-render.ts b/js13k2024/game/src/game-states/gameplay/render/bonus-render.ts index 1d2e9dc9..28f22474 100644 --- a/js13k2024/game/src/game-states/gameplay/render/bonus-render.ts +++ b/js13k2024/game/src/game-states/gameplay/render/bonus-render.ts @@ -123,6 +123,7 @@ const getBonusColor = (bonusType: BonusType): string => { return '#FF69B4'; // Hot Pink case BonusType.Builder: return '#00FFFF'; // Cyan + // @CODEGEN: Add color for other bonus types default: return '#FFFF00'; // Yellow } @@ -142,6 +143,7 @@ const getBonusSymbol = (bonusType: BonusType): string => { return 'X'; case BonusType.Builder: return 'B'; + // @CODEGEN: Add letters for other bonus types default: return '?'; } diff --git a/js13k2024/game/src/game-states/gameplay/render/grid-render.ts b/js13k2024/game/src/game-states/gameplay/render/grid-render.ts index 0e5be5ae..cbbb1ab8 100644 --- a/js13k2024/game/src/game-states/gameplay/render/grid-render.ts +++ b/js13k2024/game/src/game-states/gameplay/render/grid-render.ts @@ -1,6 +1,6 @@ import { PLATFORM_HEIGHT } from '../game-render'; import { TILE_HEIGHT, TILE_WIDTH, toIsometric } from './isometric-utils'; -import { GameState } from '../gameplay-types'; +import { BonusType, GameState, isActiveBonus } from '../gameplay-types'; export const drawPlatform = (ctx: CanvasRenderingContext2D, gridSize: number) => { const topLeft = toIsometric(0, 0); @@ -64,7 +64,7 @@ export const drawGrid = (ctx: CanvasRenderingContext2D, gridSize: number, gameSt } // Draw Slide effect if active - if (gameState.isSliding) { + if (isActiveBonus(gameState, BonusType.Slide)) { drawSlideTile(ctx, x, y); } } diff --git a/js13k2024/game/src/game-states/intro/game-preview.tsx b/js13k2024/game/src/game-states/intro/game-preview.tsx index a51f0005..336b45d5 100644 --- a/js13k2024/game/src/game-states/intro/game-preview.tsx +++ b/js13k2024/game/src/game-states/intro/game-preview.tsx @@ -52,7 +52,6 @@ const createPreviewGameState = (): GameState => ({ score: 0, gameEndingState: 'none', tsunamiLevel: 0, - isSliding: false, blasterShots: [], }); @@ -79,10 +78,12 @@ export const GamePreview: FunctionComponent = () => { gridSize: GRID_SIZE, cellSize: CELL_SIZE, initialMonsterCount: 0, + monsterSpawnSectors: [], obstacleCount: 0, initialBonusCount: 0, levelName: 'Preview', levelStory: 'Preview', + levelUpdater: () => {}, }); ctx.restore(); diff --git a/js13k2024/prompts/15_playable_levels.md b/js13k2024/prompts/15_playable_levels.md index b9d44102..f3534a69 100644 --- a/js13k2024/prompts/15_playable_levels.md +++ b/js13k2024/prompts/15_playable_levels.md @@ -1,15 +1,21 @@ +# The problem of level playability + We need to fix a problem in the gameplay. Currently on some levels the monster can spawn at a random position which makes it impossible to finish the level. Also some combinations of initial bonuses, their positions, and initial positions of monsters make it impossible to progress. It will frustrate the user Here are the problems I have noticed: -- Level4: second monster spawns almost next to the crusher bonus, and it is not possible to finish the level even though I successfuly collect the crusher bonus as intended -- Level5: I can get to the time bomb bonus, activate it, but the second monster very often spawns very close to me, and I'm not able to finish the level -- Level8: This level is not possible to complete most of the time, I activate the land mine, and can't get monsters to step on it. Also I don't know how to make use of the builder bonus -- Level9: I can't even step on the invisibility bonus -- Level10: The crusher bonus is to close to monster and I can't pick it up, but even if I would use it I don't understand what would be the benefit -- Level11: Builder bonus is too close -- Level12: I don't understand what is the idea with this setup, and how to play it. Perhaps this level should be the final pre-made setup before the final level 13. +- Level4: second monster spawns almost next to the crusher bonus, and it is not possible to finish the level even though I successfuly collect the crusher bonus as intended. The second monster should not spawn so close to crusher bonus. +- Level5: I can get to the time bomb bonus, activate it, but the second monster very often spawns very close to me, and I'm not able to finish the level. The monster should not spawn close to bomb +- Level8: This level is not possible to complete most of the time, I activate the land mine, and can't get monsters to step on it. Also I don't know how to make use of the builder bonus. Perhaps there should be no monsters initially to let me build a trap for them. +- Level9: I can't even step on the invisibility bonus. The monsters are too close initially. Monsters should be more far away. The goal should be surrounded with obstacles, and I should use invisibility to sneak to bomb to trigger it. +- Level10: The crusher bonus is to close to monster and I can't pick it up, but even if I would use it I don't understand what would be the benefit. Lets replace this level with other concept: Climber and Tsunami combo. The goal for player will be to collect tsunami bonus, and then go for climber, to get on obstacle and let monster die from tsunami. +- Level11: Builder bonus is too close. Lets change the idea of this level. Instead of current setup we could have goal spot in one of the corners of the level, and Blaster bonus to collect, and also Slide bonus to collect. +- Level12: I don't understand what is the idea with this setup, and how to play it. Perhaps this level should be the final pre-made setup before the final level 13. First of all the initial position monster position should not be se close to player. ## Level 13 This level requires additional refactor. My idea is to start from blank grid (no bonuses, no obstacles), player would start from one corner, and the goal would be on opposite corner. Bonuses will be spawned at the same time as monster spawns. The bottom line of all spawns would be to allow the player play the level continously to max up the score. So they should be getting enough of bonuses, and adequate bonuses to either eliminate monsters or protect from monsters. It means that the monster and bonus spawn should have a logic that understands how to not eliminate player with monster spawn, and also which bonus to spawn in order to let player progress. + +# Final words + +First of all update level definitions for levels 4,5,8,9,10,11 and 12. Then think how to implement the improvements for level13. You need to implement the monster spawn logic, and bonus spawn logic. Levels 1-12 should have fixed bonuses, and level 13 should have dynamic bonuses. Monster spawn logic should work for all levels.