// Mario Bookmarklet Script - V6.5 (Comprehensive Comments)
(function() {
// Prevent multiple instances of Mario on the page
if (document.getElementById('pageMarioContainer')) {
const existingMarioContainer = document.getElementById('pageMarioContainer');
// If an instance exists but is hidden, show it. Otherwise, do nothing.
if (existingMarioContainer.style.display === 'none') {
existingMarioContainer.style.display = 'block';
}
return;
}
// --- CONSTANTS ---
const SCALE_FACTOR = 5; // Visual and physics scaling factor. Mario appears 2x larger.
/**
* Parses the 5-digit hexadecimal physics values from "A Complete Guide to SMB's Physics Engine".
* Format: BSSs.sss (Blocks, Pixels, Subpixels, SubSubpixels, SubSubSubpixels)
* where each unit is 1/16th of the previous, starting from Pixels for sub-units.
* Blocks are 16 pixels.
* This function converts the guide's values into a single "guide pixel" unit for our physics.
* @param {string} hexStr - The 5-digit hexadecimal string (e.g., "01900").
* @returns {number} The value in "guide pixel" units.
*/
function parsePhysicsValue(hexStr) {
const b = parseInt(hexStr.charAt(0), 16); // Blocks (guide refers to this as 0 for vel/accel)
const p = parseInt(hexStr.charAt(1), 16); // Pixels (s.-pixels)
const s = parseInt(hexStr.charAt(2), 16); // Subpixels (ss.-pixels, 1/16th of a pixel)
const ss = parseInt(hexStr.charAt(3), 16); // SubSubpixels (1/256th of a pixel)
const sss = parseInt(hexStr.charAt(4), 16);// SubSubSubpixels (1/4096th of a pixel)
// The guide's "Blocks" digit (first char) for velocities/accelerations is usually 0.
// Our interpretation: 1 Block = 16 Pixels.
return (b * 16) + p + (s / 16.0) + (ss / 256.0) + (sss / 4096.0);
}
// Physics parameters, converted from the guide's hex values.
// These represent values like player speed (Player_X_Speed = $57, Player_Y_Speed = $9f),
// acceleration/friction (FrictionAdderLow = $0702), and jump forces (VerticalForce = $0709)
// as described abstractly in SMBDIS.ASM.
const PHYSICS = {
maxWalkSpeed: parsePhysicsValue("01900"), // Approx 1.56 guide pixels/frame
walkingAcceleration: parsePhysicsValue("00098"), // Approx 0.037 guide pixels/frame^2
maxRunSpeed: parsePhysicsValue("02900"), // Approx 2.56 guide pixels/frame
runningAcceleration: parsePhysicsValue("000E4"), // Approx 0.056 guide pixels/frame^2
releaseDeceleration: parsePhysicsValue("000D0"), // Approx 0.05 guide pixels/frame^2 (friction)
skiddingDeceleration: parsePhysicsValue("001A0"),// Approx 0.1 guide pixels/frame^2
// Jump velocities are negative because positive Y is downwards.
jumpInitialVelocitySlow: -parsePhysicsValue("04000"), // For horizontal speeds (absVx) < speedThresholdFast (02500)
jumpInitialVelocityFast: -parsePhysicsValue("05000"), // For absVx >= speedThresholdFast (02500)
// Gravity values (positive Y is downwards) based on speed category at jump initiation.
// Corresponds to "Holding 'A' Gravity" and "Falling Gravity" in the guide.
gravityA_Held_Slow: parsePhysicsValue("00200"), // absVx < speedThresholdMedium ("Less than 01000")
gravityA_Held_Medium: parsePhysicsValue("001E0"), // speedThresholdMedium <= absVx < speedThresholdFast ("01000 - 024FF")
gravityA_Held_Fast: parsePhysicsValue("00280"), // absVx >= speedThresholdFast ("02500 or greater")
gravityFalling_Slow: parsePhysicsValue("00700"),
gravityFalling_Medium: parsePhysicsValue("00600"),
gravityFalling_Fast: parsePhysicsValue("00900"),
terminalVelocity: parsePhysicsValue("04800"), // Max falling speed, approx 4.5 guide pixels/frame
// Speed thresholds from the guide for jump/gravity categorization.
speedThresholdMedium: parsePhysicsValue("01000"),
speedThresholdFast: parsePhysicsValue("02500")
};
// Sprite URLs (base64 encoded images)
const SPRITES = {
standing: 'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAgCAYAAAAbifjMAAAAAXNSR0IArs4c6QAAATxJREFUSImlVbuNwzAMfRYyQ2rBG7i4LhPktnB78AyBZzCuzRbJBOlSaAPBdYY4XRFIICXSlp1XERI/jx9RDRTcOhvys283N/nZocYQAL4GA/Tluak2VsAoaQ4kxHRYCvfTnORL1wIARueTDADH3rOgidvPgHDpWqYcMTqf5Ne1Da9rGwoHmpHmNDoxMfpiwhkLiqKNIPnnhlT+nd4NYClIUST6FKyiMZU1I9qJYjRvnQ3S4IzOJ9oURQ3upxmYLJsJADg/LDDMIXfCQtV0I9cx2kWtk+o50JwU1To/rGok3Zk90SkLcRLXmDAG0qE0B9pSaejTBIDn9LcYMXek76pKmHzDbMGx9w1jsEZf0knRtyxUkKXaLBlHpaV7tYj0F5J+pIiPu3CIEYqRdlwx3w/iTtzFYO9jUp/zVvwDv6eCnFqRE4sAAAAASUVORK5CYII=)',
crouch: 'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAgCAYAAAAbifjMAAAAAXNSR0IArs4c6QAAAR9JREFUSIntVLENwjAQvESZgdrKBi7SMYHZIm3kGRAzRLTZAiZIR5ENotQMgSmQLf/7baCj4KqP3/d/Z38M/PHHL6AqJS9aOb52WDbCaT4lAkBna6Cna/VXZAGJhVwBCYdlqxIL1/0W4qNuAQCnZQ0xAOz6NTQmugYLd9Qt2exxWtYQ36fW3afWEQuDhYu7Sp35N3K3IFmQlJxHVMGCmRXMrMgGXkhCFcv3ha77rUjy2PVregtcKj+DuAkANHH3UqEc5PEqwMwqxIOFEwvk/HtyPGziGdzGBwyUlCJkSBb4hnc5oiBI1y8VMTpbo0M6WLX39cm980ZmVi8LnMy7S2uEc9HK+T+sNBeDRdjn343kb/Q4j/SxyeXfTmJJDQA8AfHeg3BDDfSlAAAAAElFTkSuQmCC)',
jump: 'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAgCAYAAAAbifjMAAAAAXNSR0IArs4c6QAAAURJREFUSImlVbuRgzAQfdJQA7GGDggucwVcF6QeamBcg0apuzAVkF1ABwzxFXFc4uVWq4+F70Vi2fd2pX0IhZP4vjc7ret+Vfod8m1Zj+cqlfxozZ56RyJj20CdIX4MYcOewKuq02X7n0BMrIoFAWBsG4DtlUAHSDg2dR2wj23jJccgc5JjpEopQYoX+0C2TlB4tp+qliICgLPwnXhb1oAQE+Uxb4y5TmJdOQv10ok02m42gZGchap4ZQCY8JfUzSZKJDIAaE6W4MTpsqGbTZBTNEZJpOqeQIkLY11oiFPna55Ma/lJ61RVTiZSNxt82Z+wgxwZQEAKBOp+VSXJEtcBu677VdFleZaMd24kaSqde1mCijuR2zbmuqiADBCxVCj5YyHi57IpZM4nKSBBQlj8ePZjOkgZHB04C+VwfgrI3Qcl+AX6GpjZUk8DmAAAAABJRU5ErkJggg==)',
skid: 'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAgCAYAAAAbifjMAAAAAXNSR0IArs4c6QAAAWdJREFUSImdVbGRgzAQ3NO4ho8ZOlDgjAqeLpx6qMHzNTCk7gIq+MwBHXgUuwjrA8/hlZAE/o0Eul3tnY5DkMFoK5/ba2cnuj7o4tzBA8D3b5Xj4diZ1+L0fmeYvAdfp7vwswDA41p7APiZ7wCAi62hzzlHmoZRMhNj28fOYGrcsgbVyOy1zg7b2Yk6ELZ/sTVu/fNdLHqfq4fhID3h1j+XICbrvuJxrX3gQAs2NS4gx0QWDa5ktJVXchyYEhp6iDCZT8+dyPkHDvj0VN7cDxo39JADIsSn6c3EZMWuPpgal0wNcRFBbc2Ii5d0MNrKj7byardESjqIv3/txi2hrAAjlz9SjfSpyKqIPFxSs4CFgk7MiZTE2tnJaiZ+iqATSx/P1LjAhQ6UVSunBFmIRzpSRSz1w9Cv4+XcwevG3p8JI/gvtLOT1H3nyIuD3KYWrSiAjStM5b1KYSuohIAYO9kjHEyk/zj5A99XxHiqsqoTAAAAAElFTkSuQmCC)',
walk: [
'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAgCAYAAAAbifjMAAAAAXNSR0IArs4c6QAAAUJJREFUSInFVblxwzAQXGBcg2MOOkCgzBWAXSjloAaNatA4ZRd2BczRAYcxm4AD+TCHH1bijUBSt3d7uyKB/4ZoPfzSk0/vze6Iat5GCwHgYiVwje/JPxUXkEmoEZQwu0NkEr4/jnC+aQUAuLs9nAHg/bqHxtFci4W/aRX9mHB3ezifq/LnqnxGUCuqkZ6r8oFgsehq51MQijaC6U8L+fnzASH5Ra1LaXxCMYm0zBbIiYxgsfBmm8I1DxC3jxDtgIp5FvB4XpPEFE0b8Rsss01Vl8IELRuJxGh4kkRyIglce4kEAC54LreZxBZRiiwHPaQ5qS5xdIqoa+1dwG012xS91qr/hdFpujloTQMAIvW/ZyU9JxlDEqhribxLwLsShpc4u0PAAUbXY54RpF8eFBYHVyAYTWIK+UrxYuHJPflqZ8IPpxyJWPji/hAAAAAASUVORK5CYII=)',
'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAgCAYAAAAbifjMAAAAAXNSR0IArs4c6QAAATpJREFUSImlVLENwjAQPCxmoI6yQYp0TBC2oEWZAWWGiDZbwATpKLJBlJohCAWydbb/HSOuchz//b3v38Cf2Gk/7lWxhnunaYnO73MCAaBuDXCO9012sAJPkkYgwZbjlfA4Lm59rUoAQDfNbg0Ah/PsJXXaLi3Wa1V6hy26aXbr11Cur6FcIwItSCO1JMZmTxYcqGBENoLqDwN5feu/Bhj+kLJI8hnejdpStoLYiag1w16wTRTapypoxsL1QzMWAPWHLZVhtGAOtEQS9CYXINmdTaCp2CR4HBfUrVEncpOALZXsdQSpi2KENkcKcoiYRCxBI3n272hPHCYm6TAnSZ0C6cVlcIPx2eQscBCDW1q1MSdYVMAqQpLkMEnZm7HwLk+aBdUFzn7rsdPezUiSdFCS/hNBikhlzlXyAfB+icjL2GvBAAAAAElFTkSuQmCC)',
'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAgCAYAAAAbifjMAAAAAXNSR0IArs4c6QAAAU5JREFUSImllLGRgzAQRb801EDMqAMCZ66A64LUQw0eamCc0sVdBWQO6EDjmCKsC3zLrKSVhH0/Ehrt213tRwoJfbeNC/e+1ocK96ojgQBwGjTQx/v6cHBCXkkpgCRqx2vh5/zY19fWAADG1e5rAKh76yXda7sMcNfWeIdJ42r39TYbt83GRYBUUApKEE3Zsw0HVXBFYwTrPwzk69v0GoDXgpRFKp/Lu1FqpRTEJxFZE8GdECwcXxIQmom80S2N9y9ss3F1b5XYAqlbGhEi+kAaJQ9MnTvsAwJyGHJOzInGPa42DwizhZDbBKVT5fPg06D3b3obyIkqdfM58XFWketa4D49ixDSR5dIugxwEeBodmpdgT0OJQB/8kgafz9K3Vt1n57ioZwiK3dL8xakknzAIaWxFn3AZy4liwAQ/v1sBSGZLHpU/zIS+JP2aRW/G1GlQvb7go8AAAAASUVORK5CYII=)'
]
};
// Sound effect URLs and player
const SOUND_BASE_URL = 'https://raw.githubusercontent.com/mroyale/assets/refs/heads/legacy/audio/sfx/';
const SOUNDS = {
jumpBig: SOUND_BASE_URL + 'jump1.mp3',
jumpSmall: SOUND_BASE_URL + 'jump0.mp3', // Will be used if mario.isBig is false
bump: SOUND_BASE_URL + 'bump.mp3',
loaded: SOUND_BASE_URL + 'item.mp3', // Sound for Mario appearing on the page
// Sounds for future implementation
die: SOUND_BASE_URL + 'dead.mp3',
powerupCollect: SOUND_BASE_URL + 'powerup.mp3'
};
/**
* Plays a sound effect.
* @param {string} soundKey - The key for the sound in the SOUNDS object.
* @param {number} [volume=0.05] - Volume level (0.0 to 1.0).
*/
function playSound(soundKey, volume = 0.05) { // Adjusted default volume
if (SOUNDS[soundKey]) {
const audio = new Audio(SOUNDS[soundKey]);
audio.volume = volume; // Set volume BEFORE playing
audio.play().catch(e => console.warn("Sound play failed for " + soundKey + ":", e.message));
}
}
// Durations for each frame of the walk animation based on speed
const ANIM_DURATIONS = {
run_max: 2, // When at/near max run speed
run_accel: 3, // When accelerating to run or running at medium speed
walk_max: 4, // When at/near max walk speed
walk_accel: 7 // When walking slowly or accelerating to walk
};
// Mario's state object
const mario = {
el: null, // The DOM element for Mario
// x, y are bottom-center of Mario in physics units (viewport-relative)
x: (window.innerWidth / SCALE_FACTOR) / 2,
y: (window.innerHeight / SCALE_FACTOR) - 5,
previousY: 0, // Mario's y position at the start of the previous frame (for platform landing)
vx: 0, vy: 0, // Velocities in physics units per frame
baseWidth: 16, // Physics width
standingBaseHeight: 32, // Physics hitbox height when standing (Big Mario)
crouchingHitboxHeight: 16, // Physics hitbox height when crouching (Big Mario)
currentHitboxHeight: 32, // Current physics hitbox height, changes with crouching
visualHeight: 32, // Visual height of the div (fixed for Big Mario, matches standing)
onGround: false, // True if Mario is on a surface (floor or platform)
facingRight: true, // Direction Mario is visually facing
isRunning: false, // True if the run key ('Z') is held
isBig: true, // True if Mario is in his "Big" state (always true for now)
isCrouching: false, // True if Mario is currently crouching
isSkidding: false, // True if Mario is in a skidding state
jumpCategory: 'slow', // Determines jump physics ('slow', 'medium', 'fast') based on speed at takeoff
jumpStartedCrouched: false, // True if a jump was initiated while crouching
jumpKeyPressedLastFrame: false, // Tracks if jump key was pressed in the previous frame to prevent auto-jump
// Animation state
currentSpriteKey: 'standing', // Key for SPRITES object (e.g., 'standing', 'walk', 'jump')
animationFrameIndex: 0, // Current frame index for multi-frame animations (like walk)
animationTimer: 0 // Timer to control duration of the current animation frame
};
mario.currentHitboxHeight = mario.standingBaseHeight;
mario.visualHeight = mario.standingBaseHeight;
mario.previousY = mario.y;
// Input handling state
const keys = {};
let lastScrollY = window.scrollY; // Tracks last vertical page scroll position
let lastScrollX = window.scrollX; // Tracks last horizontal page scroll position
/**
* Handles key down events.
* Sets the corresponding key state and prevents default browser action for game keys.
*/
function handleKeyDown(e) {
keys[e.code] = true;
// Prevent default browser behavior (like page scrolling) for game control keys
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Space", "KeyX", "KeyZ"].includes(e.code)) {
e.preventDefault();
}
}
/**
* Handles key up events.
*/
function handleKeyUp(e) { keys[e.code] = false; }
/**
* Initializes Mario, sets up the DOM element, and starts the game loop.
*/
function init() {
// Create a container for Mario to live in (helps with z-index and positioning context)
const container = document.createElement('div'); container.id = 'pageMarioContainer';
container.style.cssText = 'position:fixed; left:0; top:0; width:100%; height:100%; pointer-events:none; z-index:99999;';
// Create Mario's DOM element
mario.el = document.createElement('div'); mario.el.id = 'pageMario';
mario.el.style.position = 'absolute';
mario.el.style.backgroundImage = SPRITES.standing; // Set initial sprite
mario.el.style.backgroundRepeat = 'no-repeat';
mario.el.style.backgroundSize = '100% 100%'; // Stretch the 16x32 sprite to fill the div
mario.el.style.imageRendering = 'pixelated'; // For crisp pixel art
// CSS hack to promote to its own compositing layer, can help prevent visual artifacts/smearing
mario.el.style.transform = 'translateZ(0px)';
// Set visual dimensions based on base size and scale factor
mario.el.style.width = (mario.baseWidth * SCALE_FACTOR) + 'px';
mario.el.style.height = (mario.visualHeight * SCALE_FACTOR) + 'px'; // Visual height is Big Mario's standing height
container.appendChild(mario.el); document.body.appendChild(container);
// Add event listeners for keyboard input
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keyup', handleKeyUp);
// Initialize scroll tracking and Mario's starting position (viewport-relative)
lastScrollY = window.scrollY; lastScrollX = window.scrollX;
mario.y = (window.innerHeight / SCALE_FACTOR) - 5; // Bottom edge 5 physics units above viewport bottom
mario.x = (window.innerWidth / SCALE_FACTOR) / 2; // Horizontal center of viewport
mario.previousY = mario.y;
playSound('loaded'); // Play sound when Mario is initialized
gameLoop(); // Start the main game loop
}
let gameLoopId = null; // ID for canceling the animation frame
let platformQueryCounter = 0; // Counter to limit frequency of DOM queries for platforms
const PLATFORM_QUERY_INTERVAL = 5; // Query for platforms every 5 frames
let currentPlatforms = []; // Cached list of potential platforms
/**
* Main game loop, calls update and render.
*/
function gameLoop() {
update();
render();
gameLoopId = requestAnimationFrame(gameLoop);
}
/**
* Updates Mario's physics, state, and handles collisions.
*/
function update() {
// --- Handle Page Scroll ---
// Adjust Mario's viewport-relative physics position to compensate for page scrolling.
// This makes him appear to stay in place relative to the page content.
const currentScrollY = window.scrollY; const scrollDeltaY = currentScrollY - lastScrollY;
if (scrollDeltaY !== 0) { const scaledScrollDelta = scrollDeltaY / SCALE_FACTOR; mario.y -= scaledScrollDelta; }
lastScrollY = currentScrollY;
const currentScrollX = window.scrollX; const scrollDeltaX = currentScrollX - lastScrollX;
if (scrollDeltaX !== 0) { const scaledScrollDelta = scrollDeltaX / SCALE_FACTOR; mario.x -= scaledScrollDelta; }
lastScrollX = currentScrollX;
// Store Mario's y position from *before* this frame's physics are applied (but *after* scroll compensation).
// Crucial for one-way platform collision detection.
mario.previousY = mario.y;
// --- Read Inputs ---
const onLeft = keys['ArrowLeft']; const onRight = keys['ArrowRight']; const onDown = keys['ArrowDown'];
const onJumpKey = keys['KeyX']; const onRun = keys['KeyZ'];
mario.isRunning = onRun;
mario.isSkidding = false; // Reset skid state at the beginning of each frame
// --- Crouching Logic (Big Mario only) ---
// SMBDIS.ASM: CrouchingFlag = $0714, PlayerSize = $0754
if (mario.isBig) {
if (mario.onGround) { // Can only change crouch state if on the ground
if (onDown && !mario.isCrouching) { // Pressing Down: Start crouching
mario.isCrouching = true;
mario.currentHitboxHeight = mario.crouchingHitboxHeight;
} else if (!onDown && mario.isCrouching && !mario.jumpStartedCrouched) { // Releasing Down: Stop crouching
// TODO: Implement ceiling check here to prevent standing up into a block
mario.isCrouching = false;
mario.currentHitboxHeight = mario.standingBaseHeight;
}
}
// Handle landing from a crouch-jump
if (mario.onGround && mario.jumpStartedCrouched) {
mario.jumpStartedCrouched = false; // Reset flag
// Re-evaluate crouch state based on whether Down is still held
if (onDown) { mario.isCrouching = true; mario.currentHitboxHeight = mario.crouchingHitboxHeight;}
else { mario.isCrouching = false; mario.currentHitboxHeight = mario.standingBaseHeight; }
}
}
// --- Horizontal Movement ---
// Inspired by assembly logic for acceleration, max speed, and friction.
// SMBDIS.ASM: Player_X_Speed = $57, RunningSpeed = $0703, FrictionAdderLow = $0702
let targetVx = 0;
let currentMaxSpeed = mario.isRunning ? PHYSICS.maxRunSpeed : PHYSICS.maxWalkSpeed;
let currentAcceleration = mario.isRunning ? PHYSICS.runningAcceleration : PHYSICS.walkingAcceleration;
// Cannot initiate new horizontal movement if crouching on the ground
const canMoveHorizontally = !(mario.isCrouching && mario.onGround);
if (canMoveHorizontally) {
if (onLeft) { targetVx = -currentMaxSpeed; }
if (onRight) { targetVx = currentMaxSpeed; }
if (onLeft && onRight) { targetVx = mario.facingRight ? currentMaxSpeed : -currentMaxSpeed; } // If both, maintain current direction
// Mid-Air Direction Lock: Only change facing direction if on ground and a new direction is input.
if (mario.onGround) {
if (onLeft && !onRight) mario.facingRight = false;
if (onRight && !onLeft) mario.facingRight = true;
}
}
if (targetVx !== 0 && canMoveHorizontally) {
// Skidding: If trying to move opposite to current velocity and not stationary.
if (Math.sign(targetVx) !== Math.sign(mario.vx) && mario.vx !== 0) {
mario.vx += Math.sign(targetVx) * PHYSICS.skiddingDeceleration;
mario.isSkidding = true;
// During skid, if on ground, face the new direction of movement intention.
if (mario.onGround) {
if (onLeft) mario.facingRight = false;
if (onRight) mario.facingRight = true;
}
} else { // Normal acceleration
mario.vx += Math.sign(targetVx) * currentAcceleration;
}
// Cap speed
if (targetVx > 0) mario.vx = Math.min(mario.vx, targetVx);
else mario.vx = Math.max(mario.vx, targetVx);
} else { // Decelerating (friction)
const decel = PHYSICS.releaseDeceleration;
if (Math.abs(mario.vx) > decel) mario.vx -= Math.sign(mario.vx) * decel;
else mario.vx = 0;
}
// --- Vertical Movement (Jumping) ---
// SMBDIS.ASM: VerticalForce = $0709 (initial jump), VerticalForceDown = $070a (gravity)
// Jump only if key is newly pressed (transition from not pressed to pressed)
if (mario.onGround && onJumpKey && !mario.jumpKeyPressedLastFrame) {
const currentAbsSpeed = Math.abs(mario.vx); // Used to determine jump height category
// Select initial jump velocity based on current horizontal speed (from guide)
if (currentAbsSpeed >= PHYSICS.speedThresholdFast) {
mario.vy = PHYSICS.jumpInitialVelocityFast; mario.jumpCategory = 'fast';
} else {
mario.vy = PHYSICS.jumpInitialVelocitySlow; // For both slow and medium initial jump height
mario.jumpCategory = (currentAbsSpeed >= PHYSICS.speedThresholdMedium) ? 'medium' : 'slow';
}
if (mario.isCrouching) mario.jumpStartedCrouched = true; // Flag if jump started from crouch
mario.onGround = false; // Mario is now in the air
playSound(mario.isBig ? 'jumpBig' : 'jumpSmall'); // Play appropriate jump sound
}
mario.jumpKeyPressedLastFrame = onJumpKey; // Store jump key state for next frame
// If crouch-jumping, ensure hitbox and crouching state remain small/active
if (mario.jumpStartedCrouched) {
mario.currentHitboxHeight = mario.crouchingHitboxHeight;
mario.isCrouching = true;
}
// --- Apply Gravity ---
if (!mario.onGround) {
let appliedGravity, heldGravity;
// Select gravity values based on speed category at the *start* of the jump
if (mario.jumpCategory === 'fast') { heldGravity = PHYSICS.gravityA_Held_Fast; appliedGravity = PHYSICS.gravityFalling_Fast; }
else if (mario.jumpCategory === 'medium') { heldGravity = PHYSICS.gravityA_Held_Medium; appliedGravity = PHYSICS.gravityFalling_Medium; }
else { heldGravity = PHYSICS.gravityA_Held_Slow; appliedGravity = PHYSICS.gravityFalling_Slow; }
// Apply less gravity if jump key is held and Mario is rising (variable jump height)
if (onJumpKey && mario.vy < 0) mario.vy += heldGravity;
else mario.vy += appliedGravity; // Apply normal falling gravity
}
// Apply terminal velocity
if (mario.vy > PHYSICS.terminalVelocity) mario.vy = PHYSICS.terminalVelocity;
// --- Update Position from Velocities ---
mario.x += mario.vx;
mario.y += mario.vy;
// Assume Mario is in the air until a collision with ground/platform sets onGround = true
mario.onGround = false;
// --- Platform Collision Detection ---
platformQueryCounter++;
if (platformQueryCounter >= PLATFORM_QUERY_INTERVAL) {
platformQueryCounter = 0;
const allElements = document.querySelectorAll('div, p, span, h1, h2, h3, h4, h5, h6, li, a, button, img, article, section, header, footer, nav, main, td, th, label, input, textarea');
currentPlatforms = [];
for (const el of allElements) {
const rect = el.getBoundingClientRect(); // Viewport-relative screen pixels
// Filter elements to be potential platforms
if (rect.width > 10 && rect.height > 2 && // Minimum dimensions
rect.width < window.innerWidth * 1.5 && rect.height < window.innerHeight * 0.75 && // Avoid huge layout blocks
el.offsetParent !== null) { // Element is rendered
const style = window.getComputedStyle(el);
if (style.display !== 'none' && style.visibility !== 'hidden' && parseFloat(style.opacity) > 0.1) {
currentPlatforms.push({ // Store platform coords in physics units
top: (rect.top / SCALE_FACTOR), bottom: (rect.bottom / SCALE_FACTOR),
left: (rect.left / SCALE_FACTOR), right: (rect.right / SCALE_FACTOR), el: el
});
}
}
}
currentPlatforms.sort((a, b) => a.top - b.top); // Sort by top edge
}
let landedOnPlatformThisFrame = false;
for (const platform of currentPlatforms) {
const marioLeft = mario.x - (mario.baseWidth / 2);
const marioRight = mario.x + (mario.baseWidth / 2);
// mario.y is current bottom, mario.previousY is previous bottom (both viewport-relative physics units)
// platform.top is also viewport-relative physics units
const landingTolerance = Math.max(5, Math.abs(mario.vy) * 1.1); // Tolerance for landing if falling fast
// One-way platform collision conditions:
if (mario.vy >= 0 && // Moving down or stationary
mario.previousY <= platform.top && // Previous bottom was at or above platform's top
mario.y >= platform.top && // Current bottom is at or below platform's top
mario.y <= platform.top + landingTolerance && // Current bottom is not too far below (passed through)
marioRight > platform.left && // Horizontal overlap
marioLeft < platform.right) {
mario.y = platform.top; // Snap to platform top
mario.vy = 0;
mario.onGround = true;
landedOnPlatformThisFrame = true;
// Handle crouch state on landing
if (mario.isBig && mario.jumpStartedCrouched) {
mario.jumpStartedCrouched = false;
if (onDown) { mario.isCrouching = true; mario.currentHitboxHeight = mario.crouchingHitboxHeight;}
else { mario.isCrouching = false; mario.currentHitboxHeight = mario.standingBaseHeight; }
}
break; // Stop checking other platforms for this frame
}
}
// --- Viewport Floor Collision ---
const worldFloor = (window.innerHeight / SCALE_FACTOR); // Viewport bottom in physics units
if (!landedOnPlatformThisFrame && mario.y >= worldFloor) {
mario.y = worldFloor;
mario.vy = 0;
mario.onGround = true;
// Handle crouch state on landing
if (mario.isBig && mario.jumpStartedCrouched) {
mario.jumpStartedCrouched = false;
if (onDown) { mario.isCrouching = true; mario.currentHitboxHeight = mario.crouchingHitboxHeight;}
else { mario.isCrouching = false; mario.currentHitboxHeight = mario.standingBaseHeight; }
}
}
// --- Skid on Landing Logic ---
// If just landed (onGround is true now) and wasn't already skidding from input,
// and horizontal velocity is opposite to facing direction.
if (mario.onGround && !mario.isSkidding && mario.vx !== 0 &&
((mario.facingRight && mario.vx < 0) || (!mario.facingRight && mario.vx > 0))
) {
mario.isSkidding = true;
}
// --- Viewport Edge Collisions (Ceiling, Left/Right Walls) ---
const worldRightEdge = (window.innerWidth / SCALE_FACTOR);
const worldLeftEdge = 0;
const worldCeiling = 0; // Viewport top in physics units
// Ceiling collision (uses current hitbox height)
if (mario.y - mario.currentHitboxHeight < worldCeiling) {
mario.y = worldCeiling + mario.currentHitboxHeight; // Correct position
if (mario.vy < 0) { // If was moving up
mario.vy = 0; // Bonk
playSound('bump');
}
}
// Side wall collisions
if (mario.x - (mario.baseWidth / 2) < worldLeftEdge) { mario.x = worldLeftEdge + (mario.baseWidth / 2); mario.vx = 0; }
if (mario.x + (mario.baseWidth / 2) > worldRightEdge) { mario.x = worldRightEdge - (mario.baseWidth / 2); mario.vx = 0; }
// --- Animation State Logic ---
// SMBDIS.ASM: PlayerAnimTimer = $0781, PlayerAnimCtrl = $070d
if (mario.isCrouching || mario.jumpStartedCrouched) {
mario.currentSpriteKey = 'crouch';
} else if (!mario.onGround) {
mario.currentSpriteKey = 'jump';
} else if (mario.isSkidding) {
mario.currentSpriteKey = 'skid';
} else if (mario.vx !== 0) { // Walking or Running
mario.currentSpriteKey = 'walk';
mario.animationTimer--;
if (mario.animationTimer <= 0) {
mario.animationFrameIndex = (mario.animationFrameIndex + 1) % 3; // Cycle Walk1, Walk2, Walk3 (0, 1, 2)
const absVx = Math.abs(mario.vx);
// Set animation frame duration based on current speed
if (absVx >= PHYSICS.maxRunSpeed * 0.85) { // Approx max run speed
mario.animationTimer = ANIM_DURATIONS.run_max;
} else if (absVx > PHYSICS.maxWalkSpeed * 0.90) { // Running or fast walk (threshold between walk and run_accel)
mario.animationTimer = ANIM_DURATIONS.run_accel;
} else if (absVx > PHYSICS.maxWalkSpeed * 0.50) { // Medium to max walk speed
mario.animationTimer = ANIM_DURATIONS.walk_max;
} else if (absVx > 0.01) { // Slow walk / just starting
mario.animationTimer = ANIM_DURATIONS.walk_accel;
}
// If vx becomes 0, next state will be 'standing', resetting animTimer explicitly isn't critical here
}
} else { // Idle
mario.currentSpriteKey = 'standing';
mario.animationFrameIndex = 0; // Reset walk frame index when idle
}
}
/**
* Renders Mario on the screen based on his current state.
*/
function render() {
if (!mario.el) return;
// Div visual height is always Big Mario's standing height
const renderDivHeight = mario.visualHeight * SCALE_FACTOR;
const renderDivWidth = mario.baseWidth * SCALE_FACTOR;
// Calculate CSS top-left from Mario's physics bottom-center coordinates
const renderX = (mario.x - mario.baseWidth / 2) * SCALE_FACTOR;
// Mario's y is bottom, visualHeight is the full height of the sprite/div
const renderY = (mario.y - mario.visualHeight) * SCALE_FACTOR;
mario.el.style.left = renderX + 'px';
mario.el.style.top = renderY + 'px';
mario.el.style.width = renderDivWidth + 'px';
mario.el.style.height = renderDivHeight + 'px';
// Flip sprite based on direction, and include translateZ for layer promotion
const scaleXValue = mario.facingRight ? '1' : '-1';
mario.el.style.transform = `scaleX(${scaleXValue}) translateZ(0px)`;
// Select current sprite image
let currentSpriteImage = SPRITES.standing; // Default
if (mario.currentSpriteKey === 'walk') {
currentSpriteImage = SPRITES.walk[mario.animationFrameIndex];
} else if (SPRITES[mario.currentSpriteKey]) { // For single-frame states like jump, crouch, skid
currentSpriteImage = SPRITES[mario.currentSpriteKey];
}
mario.el.style.backgroundImage = currentSpriteImage;
// Each sprite is 16x32. The div will be sized to show this.
// '100% 100%' makes the sprite image fill the div.
mario.el.style.backgroundSize = '100% 100%';
}
init(); // Start Page Mario!
})();