xxxxxxxxxx
560
// sketch.js - Typographic Runner (Mobile Touch Friendly)
// --- Global Game Variables ---
let playerA;
let obstacles = [];
let skyline = [];
let score = 0;
let gameSpeed;
let gravity = 0.7;
let jumpPower = -12;
let groundY;
let gameOver = false;
let gameStarted = false;
let playerLetter = 'A';
// --- Constants ---
const PLAYER_SIZE = 50;
const OBSTACLE_SIZE = 50;
const PLAYER_WIDTH = PLAYER_SIZE * 0.6;
const PLAYER_HEIGHT = PLAYER_SIZE * 0.8;
const OBSTACLE_WIDTH = OBSTACLE_SIZE * 0.8;
const OBSTACLE_HEIGHT = OBSTACLE_SIZE * 0.8;
const FONT = 'Courier New'; // Default font for UI and Player
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
// --- Fonts for obstacles ---
// Added generic CSS font families for more variation.
// For specific fonts (e.g., from Google Fonts), link them in your HTML
// and add their exact names here, or use loadFont() in preload().
const OBSTACLE_FONTS = [
'Arial',
'Georgia',
'Verdana',
'Times New Roman',
'Helvetica',
FONT, // Include the default font as well
'serif', // Generic serif (e.g., Times New Roman)
'sans-serif', // Generic sans-serif (e.g., Arial)
'monospace', // Generic monospace (e.g., Courier New)
'cursive', // Generic cursive (e.g., Comic Sans MS - varies widely!)
'fantasy' // Generic fantasy (e.g., Impact - varies widely!)
];
// --- Difficulty Settings ---
const INITIAL_GAME_SPEED = 5;
const MAX_GAME_SPEED = 15;
const SPEED_INCREMENT = 0.0015;
const INITIAL_SPAWN_RATE_MIN = 90;
const INITIAL_SPAWN_RATE_MAX = 170;
const MIN_OBSTACLE_DISTANCE_BASE = 200;
const MIN_OBSTACLE_DISTANCE_SCALE = 18;
// --- UI Elements (for Character Selection) ---
let changeButtonX, changeButtonY, changeButtonW, changeButtonH;
// ==============================
// --- P5.js Core Functions ---
// ==============================
// --- preload() function ---
// Use this to load specific font files if you have them:
// let myCustomFont;
// function preload() {
// myCustomFont = loadFont('assets/myfont.ttf');
// // Then you could add myCustomFont to the OBSTACLE_FONTS array,
// // but you need to ensure the array is defined *after* preload runs,
// // or push to it in setup().
// }
function setup() {
createCanvas(windowWidth > 800 ? 800 : windowWidth, 400);
groundY = height - 50;
textFont(FONT); // Set default font for UI elements initially
textAlign(CENTER, CENTER);
textSize(20); // Default text size
// Define button dimensions (relative to screen size)
changeButtonW = 180;
changeButtonH = 40;
changeButtonX = width / 2 - changeButtonW / 2;
// Position button below the selected letter display
changeButtonY = height / 2 + 45;
// Initialize skyline elements
for (let i = 0; i < 7; i++) {
skyline.push(new SkylineElement(random(width, width * 2)));
}
}
function draw() {
background(240, 240, 240);
// Draw ground
push(); // Isolate ground style
fill(50);
noStroke();
rect(0, groundY, width, height - groundY);
pop(); // Restore previous style
// Draw skyline elements
drawSkyline();
// Handle game states
if (!gameStarted) {
showStartScreen();
return; // Don't draw game elements if not started
}
if (gameOver) {
showGameOverScreen();
return; // Don't draw game elements if game over
}
// --- Game is running ---
handleObstacles(); // Update, draw, and check collisions for obstacles
// Ensure player exists before updating/showing
if (playerA) {
playerA.update();
playerA.show();
}
updateScore(); // Update score based on time/speed
displayScore(); // Show the current score
// Increase game speed gradually
if (gameSpeed < MAX_GAME_SPEED) {
gameSpeed += SPEED_INCREMENT;
}
gameSpeed = min(gameSpeed, MAX_GAME_SPEED); // Cap game speed
}
// ==============================
// --- Input Handling ---
// ==============================
// Keep keyboard controls for desktop users
function keyPressed() {
if (!gameStarted) {
// Start game with SPACE or ENTER
if (key === ' ' || keyCode === ENTER) {
startGame();
}
} else if (!gameOver) {
// Jump with SPACE or UP_ARROW
if (key === ' ' || keyCode === UP_ARROW) {
handleInput(); // Trigger jump
}
} else { // Game is over
// Restart with ENTER
if (keyCode === ENTER) {
restartGame();
}
}
}
// Primary handler for mouse clicks
function mousePressed() {
handleTouchOrClick(mouseX, mouseY);
}
// Primary handler for touch events
function touchStarted() {
// Use the first touch point's coordinates
if (touches.length > 0) {
handleTouchOrClick(touches[0].x, touches[0].y);
}
return false; // Prevent default browser touch behaviors (like scrolling)
}
// Unified logic for both mouse clicks and touch taps
function handleTouchOrClick(x, y) {
if (!gameStarted) {
// Check if the "Change Letter" button was pressed
if (x > changeButtonX && x < changeButtonX + changeButtonW &&
y > changeButtonY && y < changeButtonY + changeButtonH) {
cyclePlayerLetter(); // Change player character
return; // Don't start the game if button was pressed
} else {
// Otherwise, start the game on tap/click anywhere else
startGame();
}
} else if (!gameOver) {
// If game is running, jump
handleInput();
} else { // Game is over
// Restart the game on tap/click
restartGame();
}
}
// Central input action for jumping
function handleInput() {
if (playerA) { // Make sure player exists
playerA.jump();
}
}
// Cycles the playerLetter through the alphabet
function cyclePlayerLetter() {
let currentIndex = ALPHABET.indexOf(playerLetter);
let nextIndex = (currentIndex + 1) % ALPHABET.length; // Wrap around
playerLetter = ALPHABET[nextIndex];
}
// ==============================
// --- Game State Management ---
// ==============================
function startGame() {
gameStarted = true;
restartGame(); // Initialize game variables for a new game
}
function showStartScreen() {
push(); // Isolate start screen styles
textFont(FONT); // Ensure default font for UI
textAlign(CENTER, CENTER);
// Title
fill(0);
textSize(40);
text("TYPO RUNNER", width / 2, height / 2 - 70); // Adjusted position
// Selected Character Display
textSize(22);
fill(0, 100, 200); // Blue color for player letter display
text(`Play as: ${playerLetter}`, width / 2, height / 2 - 10);
// "Change Letter" Button
fill(150); // Button background color
stroke(50); // Button border
rect(changeButtonX, changeButtonY, changeButtonW, changeButtonH, 5); // Rounded corners
fill(0); // Text color for button
noStroke();
textSize(16);
text("Change Letter", width / 2, changeButtonY + changeButtonH / 2);
// Start Instruction
fill(0); // Reset fill for instruction text
textSize(18);
text("Tap Screen or Press SPACE to Start", width / 2, changeButtonY + changeButtonH + 30); // Position below button
pop(); // Restore previous styles
}
function showGameOverScreen() {
push(); // Isolate game over screen styles
textFont(FONT); // Ensure default font for UI
textAlign(CENTER, CENTER);
// Semi-transparent overlay
fill(100, 100, 100, 180);
rect(0, 0, width, height);
// Game Over Text
fill(20); // Dark text color
textSize(50);
text("GAME OVER", width / 2, height / 2 - 40);
// Final Score
textSize(25);
text(`Score: ${floor(score)}`, width / 2, height / 2 + 10);
// Restart Instruction
textSize(18);
text("Tap Screen or Press ENTER to Restart", width / 2, height / 2 + 50);
pop(); // Restore previous styles
}
function restartGame() {
obstacles = []; // Clear obstacles
score = 0; // Reset score
gameSpeed = INITIAL_GAME_SPEED; // Reset game speed
playerA = new Player(playerLetter); // Create new player with selected letter
gameOver = false; // Reset game over flag
gameStarted = true; // Ensure game is marked as started
frameCount = 0; // Reset frameCount for consistent obstacle spawning
loop(); // Ensure the draw loop is running (in case it was stopped)
}
// ==============================
// --- Game Elements Update & Display ---
// ==============================
function updateScore() {
// Score increases based on survival time and current speed
score += (gameSpeed / INITIAL_GAME_SPEED) * 0.1;
}
function displayScore() {
push(); // Isolate score display style
fill(0);
textSize(20);
textFont(FONT); // Use the default UI font
textAlign(RIGHT, TOP); // Align score to top-right
text(`Score: ${floor(score)}`, width - 20, 20);
pop(); // Restore previous style (including textAlign)
}
function handleObstacles() {
// --- Obstacle Spawning Logic ---
// Calculate time between spawns, adjusted by game speed
let spawnCheckInterval = floor(random(INITIAL_SPAWN_RATE_MIN, INITIAL_SPAWN_RATE_MAX) / (gameSpeed / INITIAL_GAME_SPEED));
spawnCheckInterval = max(20, spawnCheckInterval); // Prevent spawning too rapidly
// Only start spawning after a brief delay and at calculated intervals
if (frameCount > 40 && frameCount % spawnCheckInterval === 0) {
// Calculate minimum distance based on speed
let minDistance = MIN_OBSTACLE_DISTANCE_BASE + gameSpeed * MIN_OBSTACLE_DISTANCE_SCALE;
let safeToSpawn = true;
// Check distance from the last obstacle if one exists
if (obstacles.length > 0) {
let lastObstacle = obstacles[obstacles.length - 1];
let potentialSpawnX = width + OBSTACLE_WIDTH / 2; // Where the new one would start
if (potentialSpawnX - lastObstacle.x < minDistance) {
safeToSpawn = false; // Too close to the previous one
}
}
// Spawn if it's safe
if (safeToSpawn) {
obstacles.push(new Obstacle());
}
}
// --- Obstacle Update, Display, and Collision Check ---
// Loop backwards for safe removal
for (let i = obstacles.length - 1; i >= 0; i--) {
obstacles[i].update();
obstacles[i].show();
// Check for collision with player
if (playerA && playerA.hits(obstacles[i])) {
gameOver = true; // Set game over flag
// Optionally: noLoop(); // Stop the draw loop immediately
// Optionally: add sound effect, screen shake etc.
}
// Remove obstacles that have moved off-screen
if (obstacles[i].isOffscreen()) {
obstacles.splice(i, 1); // Remove from array
}
}
}
function drawSkyline() {
// Don't draw if skyline is empty before game starts (though setup adds some)
if (!gameStarted && skyline.length === 0) return;
// Loop backwards for potential removal/recycling (though recycling happens inside)
for (let i = skyline.length - 1; i >= 0; i--) {
// Use current game speed if defined, otherwise a slow default speed
let currentSpeed = (typeof gameSpeed !== 'undefined') ? gameSpeed : 1;
skyline[i].update(currentSpeed);
skyline[i].show();
// Recycle skyline elements that go off-screen
if (skyline[i].isOffscreen()) {
// Reposition off-screen to the right with some randomness
skyline[i].x = width + random(50, 200);
// Give it a new random appearance
skyline[i].resetAppearance();
}
}
// Add new skyline elements occasionally if needed to maintain density
if (gameStarted && skyline.length < 7 && frameCount % 50 === 0) {
skyline.push(new SkylineElement(width + random(50, 200)));
}
}
// ==============================
// --- Classes ---
// ==============================
class Player {
constructor(chosenLetter) {
this.char = chosenLetter;
this.size = PLAYER_SIZE;
this.w = PLAYER_WIDTH; // Width for collision box
this.h = PLAYER_HEIGHT; // Height for collision box
this.x = 60; // Player's horizontal position (fixed)
this.baseY = groundY - this.h / 2; // Player's ground Y position (center aligned)
this.y = this.baseY; // Player's current Y position
this.velocityY = 0; // Player's vertical speed
}
jump() {
// Allow jump only when on or very near the ground
if (this.y >= this.baseY - 5) { // Small tolerance
this.velocityY = jumpPower;
}
}
hits(obstacle) {
// Simple AABB (Axis-Aligned Bounding Box) collision detection
let playerLeft = this.x - this.w / 2;
let playerRight = this.x + this.w / 2;
let playerTop = this.y - this.h / 2;
let playerBottom = this.y + this.h / 2;
let obsLeft = obstacle.x - obstacle.w / 2;
let obsRight = obstacle.x + obstacle.w / 2;
let obsTop = obstacle.y - obstacle.h / 2;
let obsBottom = obstacle.y + obstacle.h / 2;
// Check for overlap
return (
playerRight > obsLeft &&
playerLeft < obsRight &&
playerBottom > obsTop &&
playerTop < obsBottom
);
}
update() {
// Apply gravity
this.velocityY += gravity;
// Update vertical position
this.y += this.velocityY;
// Prevent falling through the ground
if (this.y >= this.baseY) {
this.y = this.baseY;
this.velocityY = 0; // Stop vertical movement when on ground
}
}
show() {
push(); // Isolate player style
fill(0, 100, 200); // Player color (blue)
textSize(this.size);
textFont(FONT); // Use default game font for player
textStyle(BOLD);
textAlign(CENTER, CENTER); // Ensure centered text
text(this.char, this.x, this.y);
// textStyle(NORMAL); // textStyle is reset by pop()
pop(); // Restore previous style
}
}
class Obstacle {
constructor(startX = width + OBSTACLE_WIDTH / 2) { // Default start position off-screen right
this.size = OBSTACLE_SIZE;
this.w = OBSTACLE_WIDTH; // Width for collision box
this.h = OBSTACLE_HEIGHT; // Height for collision box
this.x = startX;
this.y = groundY - this.h / 2; // Obstacle's Y position (center aligned on ground)
// --- Select obstacle letter (avoiding player letter) ---
let possibleObstacles = ALPHABET.filter(char => char !== playerLetter.toUpperCase());
// Use 'X' if somehow the player letter is the only one left (edge case)
this.letter = (possibleObstacles.length > 0) ? random(possibleObstacles) : 'X';
// --- Assign a random font from the expanded list ---
this.font = random(OBSTACLE_FONTS);
}
update() {
// Move left based on current game speed
this.x -= gameSpeed;
}
isOffscreen() {
// Check if obstacle is completely off the left edge
return this.x < -this.w / 2;
}
show() {
push(); // Isolate obstacle style (IMPORTANT for multiple fonts)
fill(30); // Obstacle color (dark grey)
textSize(this.size);
textFont(this.font); // *** Use the obstacle's assigned font ***
textStyle(BOLD);
textAlign(CENTER, CENTER); // Ensure centered text
text(this.letter, this.x, this.y);
// textStyle(NORMAL); // textStyle is reset by pop()
pop(); // Restore previous style (font, size, fill, alignment, etc.)
}
}
class SkylineElement {
constructor(startX) {
this.x = startX; // Initial horizontal position
this.speedFactor = random(0.1, 0.4); // Slower speed than gameSpeed for parallax
this.resetAppearance(); // Set initial random appearance
}
resetAppearance() {
// Randomly decide if it's a word or single letter
this.isWord = random(1) < 0.3; // 30% chance of being a word
this.txtSize = random(15, 45); // Random text size
this.h = this.txtSize; // Height based on text size
// Select text content and calculate width
if (this.isWord) {
this.text = random(['UP', 'BIG', 'TYPO', 'CITY', 'HIGH', 'RUN', 'CODE', 'FONT']); // Example words
// Approximate width (adjust factor as needed for the font)
this.w = (this.text.length * this.txtSize * 0.6);
} else {
this.text = random(ALPHABET); // Random single letter
this.w = this.txtSize * 0.6; // Approximate width
}
// Set vertical position (random height above ground)
this.y = groundY - random(5, 150) - this.h / 2;
// Set color (semi-transparent grey)
this.color = color(180, 180, 180, 150);
}
update(currentGlobalSpeed) {
// Move left based on global speed and the element's speed factor
this.x -= currentGlobalSpeed * this.speedFactor;
}
isOffscreen() {
// Check if element is completely off the left edge
return this.x < -this.w; // Check against its own width
}
show() {
push(); // Isolate skyline element style
fill(this.color);
textSize(this.txtSize);
textFont(FONT); // Use default font for skyline elements
noStroke();
textAlign(CENTER, CENTER); // Ensure centered text
text(this.text, this.x, this.y);
pop(); // Restore previous style
}
}
// ==============================
// --- Utility Functions ---
// ==============================
function windowResized() {
// Adjust canvas size (up to max width)
resizeCanvas(windowWidth > 800 ? 800 : windowWidth, 400);
// Recalculate ground position
groundY = height - 50;
// Recalculate button position on resize
changeButtonX = width / 2 - changeButtonW / 2;
changeButtonY = height / 2 + 45; // Keep relative position
// Adjust player's base Y position and current Y if necessary
if (playerA) {
playerA.baseY = groundY - playerA.h / 2;
// Prevent player from ending up below the new ground level
playerA.y = min(playerA.y, playerA.baseY);
}
// Optional: Reposition obstacles and skyline elements if needed,
// though typically letting them run off/respawn is fine.
}