From 4fb841c298105821ee5f2fe62c780c6420873fd4 Mon Sep 17 00:00:00 2001 From: Jeremy Leland Date: Fri, 2 Feb 2024 21:55:03 -0800 Subject: [PATCH] Rasterization work --- games/Frogger/editor.html | 180 ++++++++++----------- games/Frogger/index.html | 22 ++- games/Frogger/levels/new/bridge.json | 2 +- games/Frogger/src/Entity.js | 59 +++++-- games/Frogger/src/Frog.js | 42 ++--- games/Frogger/src/FroggerCanvas.js | 4 - games/Frogger/src/Froggy.js | 4 +- games/Frogger/src/Level.js | 24 --- games/Frogger/src/TileMap.js | 62 +++++-- games/Frogger/src/World.js | 144 +++++++++-------- games/Frogger/src/common/AnimatedCanvas.js | 47 +++++- games/Frogger/src/entities/Turtle.js | 65 ++++---- games/Frogger/test/drawTest.html | 30 ++-- games/Frogger/test/loadLevel.html | 8 +- games/Frogger/ui.css | 1 + 15 files changed, 405 insertions(+), 289 deletions(-) diff --git a/games/Frogger/editor.html b/games/Frogger/editor.html index ba39fd3..1540d64 100644 --- a/games/Frogger/editor.html +++ b/games/Frogger/editor.html @@ -72,7 +72,7 @@ import { Tiles } from './src/Tiles.js'; import { Direction, Dir } from './src/Entity.js'; import { Entities } from './src/Entities.js'; - import { Entity } from './src/Entity.js'; + import * as Entity from './src/Entity.js'; import { Froggy } from './src/Froggy.js'; import { Level } from './src/Level.js'; import { World } from './src/World.js'; @@ -85,6 +85,77 @@ let DebugGrid = true; + // + // UI + // + + const worldDiv = document.getElementById( 'world' ); + + const uiDiv = document.getElementById( 'ui' ); + + const tilesDiv = document.getElementById( 'tiles' ); + for ( const tile in Tiles ) { + tilesDiv.appendChild( getCanvasButton( Tiles[ tile ].draw, tile, 'Tiles' ) ); + } + + const entitiesDiv = document.getElementById( 'entities' ); + for ( const id in Entities ) { + entitiesDiv.appendChild( getCanvasButton( Entities[ id ].draw, id, 'Entities' ) ); + } + + function getCanvasButton( drawFunc, brush, type ) { + const button = document.createElement( 'button' ); + + // TODO: Use AnimatedCanvas for these, and just don't start it? + const icon = new Canvas( 48, 48 ); + icon.ctx.scale( 48, 48 ); + icon.ctx.translate( 0.5, 0.5 ); + icon.ctx.lineWidth = 1 / 48; // TODO: Can we set this once and not deal with it anymore? bug #38 + drawFunc( icon.ctx ); + button.appendChild( icon.canvas ); + button.appendChild( document.createElement( 'br' ) ); + + const text = document.createElement( 'div' ); + text.innerHTML = brush; + button.appendChild( text ); + + button.dataset.brush = brush; + button.dataset.type = type; + + return button; + } + + tilesDiv.addEventListener( 'click', brushClick ); + entitiesDiv.addEventListener( 'click', brushClick ); + + function brushClick( e ) { + const button = e.target.closest( 'button' ); + + if ( button ) { + activeBrush = button.dataset.brush; + activeType = button.dataset.type; + } + } + + const timeUI = document.getElementById( 'time' ); + timeUI.addEventListener( 'input', e => level.time = parseInt( e.target.value ) ); + + + const buttonFuncs = { + 'path': _ => activeType = EditType.Directions, + 'clear': clearLevel, + 'load': loadLevel, + 'save': saveLevel, + 'play': startPlay, + 'pause': pausePlay, + 'stop': stopPlay, + } + + for ( const id in buttonFuncs ) { + document.getElementById( id ).addEventListener( 'click', buttonFuncs[ id ] ); + } + + const Mode = { Edit: 0, Play: 1 }; let mode = Mode.Edit; @@ -94,7 +165,8 @@ let level = Object.assign( getEmptyLevel(), JSON.parse( localStorage.getItem( EditorLevelKey ) ) ); const canvas = new FroggerCanvas( document.getElementById( 'canvas' ) ); - canvas.scale = TILE_SIZE; + canvas.ctx.scaleVal = TILE_SIZE; + levelResized(); // @@ -109,6 +181,8 @@ function clearLevel() { level = getEmptyLevel(); + + timeUI.value = level.time; levelResized(); } @@ -165,11 +239,13 @@ let currentDirection = Direction.None; function setEntityDirection( dir ) { - const entity = level.entities.find( e => e.x == mouseCol && e.y == mouseRow ); - if ( entity ) { - entity.dir = dir; - redraw(); + level.entities.filter( e => e.x == mouseCol && e.y == mouseRow ).forEach( e => e.dir = dir ); + + if ( level.spawn.x == mouseCol && level.spawn.y == mouseRow ) { + level.spawn.dir = dir; } + + redraw(); } function toggleGrid() { @@ -195,8 +271,11 @@ function redraw() { const ctx = canvas.ctx; + ctx.clearRect( 0, 0, ctx.canvas.width, ctx.canvas.height ); + ctx.save(); { - ctx.scale( TILE_SIZE, TILE_SIZE ); + ctx.setTransform( ctx.scaleVal * devicePixelRatio, 0, 0, ctx.scaleVal * devicePixelRatio, 0, 0 ); + ctx.translate( 0.5, 0.5 ); level.draw( ctx ); level.entities.forEach( entity => @@ -207,8 +286,7 @@ ctx.fillStyle = ctx.strokeStyle = ARROW_COLOR; ctx.lineWidth = TILE_BORDER; ctx.textAlign = 'center'; - ctx.font = '10px Arial'; // work around https://bugzilla.mozilla.org/show_bug.cgi?id=1845828 - + ctx.font = '0.2px Arial'; for ( let row = 0; row < level.rows; row ++ ) { ctx.save(); { @@ -224,11 +302,8 @@ ctx.restore(); } - ctx.save(); - ctx.scale( 0.02, 0.02 ); // work around https://bugzilla.mozilla.org/show_bug.cgi?id=1845828 - ctx.fillText( `(${ col },${ row })`, 0, 20 ); - ctx.restore(); - + ctx.fillText( `(${ col },${ row })`, 0, 0.4 ); + ctx.translate( 1, 0 ); } @@ -239,79 +314,6 @@ } } ctx.restore(); - - timeUI.value = level.time; - } - - - // - // UI - // - - const worldDiv = document.getElementById( 'world' ); - - const uiDiv = document.getElementById( 'ui' ); - - const tilesDiv = document.getElementById( 'tiles' ); - for ( const tile in Tiles ) { - tilesDiv.appendChild( getCanvasButton( Tiles[ tile ].draw, tile, 'Tiles' ) ); - } - - const entitiesDiv = document.getElementById( 'entities' ); - for ( const id in Entities ) { - entitiesDiv.appendChild( getCanvasButton( Entities[ id ].draw, id, 'Entities' ) ); - } - - function getCanvasButton( drawFunc, brush, type ) { - const button = document.createElement( 'button' ); - - // TODO: Use AnimatedCanvas for these, and just don't start it? - const icon = new Canvas( 48, 48 ); - icon.ctx.scale( 48, 48 ); - icon.ctx.translate( 0.5, 0.5 ); - icon.ctx.lineWidth = 1 / 48; // TODO: Can we set this once and not deal with it anymore? bug #38 - drawFunc( icon.ctx ); - button.appendChild( icon.canvas ); - button.appendChild( document.createElement( 'br' ) ); - - const text = document.createElement( 'div' ); - text.innerHTML = brush; - button.appendChild( text ); - - button.dataset.brush = brush; - button.dataset.type = type; - - return button; - } - - tilesDiv.addEventListener( 'click', brushClick ); - entitiesDiv.addEventListener( 'click', brushClick ); - - function brushClick( e ) { - const button = e.target.closest( 'button' ); - - if ( button ) { - activeBrush = button.dataset.brush; - activeType = button.dataset.type; - } - } - - const timeUI = document.getElementById( 'time' ); - timeUI.addEventListener( 'input', e => level.time = parseInt( e.target.value ) ); - - - const buttonFuncs = { - 'path': _ => activeType = EditType.Directions, - 'clear': clearLevel, - 'load': loadLevel, - 'save': saveLevel, - 'play': startPlay, - 'pause': pausePlay, - 'stop': stopPlay, - } - - for ( const id in buttonFuncs ) { - document.getElementById( id ).addEventListener( 'click', buttonFuncs[ id ] ); } @@ -408,10 +410,6 @@ document.addEventListener( 'keydown', e => KeyBindings[ e.code ]?.() ); - - levelResized(); - // redraw(); - window.addEventListener( 'beforeunload', ( e ) => localStorage.setItem( EditorLevelKey, JSON.stringify( level ) ) ); diff --git a/games/Frogger/index.html b/games/Frogger/index.html index 2d1e53c..937d5f3 100644 --- a/games/Frogger/index.html +++ b/games/Frogger/index.html @@ -1,4 +1,4 @@ -Frogger v0.9 +Frogger v0.92 @@ -100,6 +100,8 @@ import { Direction } from './src/Entity.js'; import { Level } from './src/Level.js'; import { World } from './src/World.js'; + import * as TileMap from './src/TileMap.js'; + import * as Entity from './src/Entity.js'; import * as Utility from './src/common/Utility.js'; import { FroggerCanvas } from './src/FroggerCanvas.js'; @@ -277,12 +279,28 @@ } } ); + // TODO: Remind me why AnimatedCanvas couldn't handle this directly? const wrapper = document.getElementById( 'wrapper' ); window.onresize = ( e ) => { const width = Math.floor( wrapper.clientWidth ); const height = Math.floor( wrapper.clientHeight ); + canvas.ctx.scaleVal = height / 16; // TODO: Account for width < height? canvas.setSize( width, height ); - canvas.scale = height / 16; + + TileMap.Rasterized.image = null; + + for ( const type in Entity.Rasterized ) { + Entity.Rasterized[ type ] = null; + // const imgWidth = canvas.ctx.scaleVal * devicePixelRatio; + // const imgHeight = canvas.ctx.scaleVal * devicePixelRatio; + + // Entity.Rasterized[ type ].image.width = imgWidth; + // Entity.Rasterized[ type ].image.height = imgHeight; + // Entity.Rasterized[ type ].needsRedraw = true; + + // Entity.Rasterized[ type ].ctx.scale( imgWidth, imgHeight ); + // Entity.Rasterized[ type ].ctx.translate( 0.5, 0.5 ); + } }; window.onresize(); diff --git a/games/Frogger/levels/new/bridge.json b/games/Frogger/levels/new/bridge.json index 23c9ab2..42fdbe1 100644 --- a/games/Frogger/levels/new/bridge.json +++ b/games/Frogger/levels/new/bridge.json @@ -1 +1 @@ -{"cols":15,"rows":15,"tileInfoKeys":["Grass","Water","Road","Bush","Lilypad"],"tiles":[1,1,0,0,0,0,3,0,3,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,4,1,1,1,4,1,1,1,4,1,1,0,0,0,1,1,1,4,1,1,1,4,1,1,1,1,0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,3,1,1,1,1,3,0,3,1,1,1,1,3,0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,1,1,1,1,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,1,1,4,1,1,3,1,4,1,0,0,3,0,0,3,0,1,1,4,1,0,4,1,1,0,0,0,0,3,0,0,0,1,1,1,1,1,1,0,3,0,0,3,0,0,3,0,1,1,1,1],"directions":[0,1,0,0,0,0,0,0,0,0,0,0,0,3,0,0,1,2,2,2,2,2,2,2,2,2,2,2,2,0,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,3,2,2,2,2,2,2,2,2,2,2,2,0,0,2,2,4,4,4,4,4,4,4,4,3,0,1,2,0,0,0,1,0,0,0,0,0,0,0,4,3,0,1,0,0,4,1,0,0,0,0,0,0,0,0,3,0,1,2,0,1,0,0,0,0,0,0,0,0,0,3,0,0,0],"entities":[{"type":"BlueCar","x":3,"y":6},{"type":"BlueCar","x":9,"y":6},{"type":"Froggy1","x":3,"y":0,"dir":3},{"type":"Froggy2","x":10,"y":0,"dir":3},{"type":"Froggy3","x":0,"y":7,"dir":4},{"type":"Froggy4","x":14,"y":7,"dir":2},{"type":"Froggy5","x":4,"y":13,"dir":1},{"type":"RedCar","x":12,"y":5},{"type":"RedCar","x":6,"y":5},{"type":"RedCar","x":1,"y":5},{"type":"LogMiddle","x":2,"y":2},{"type":"LogStart","x":7,"y":2},{"type":"LogStart","x":1,"y":2},{"type":"LogEnd","x":3,"y":2},{"type":"LogEnd","x":8,"y":2},{"type":"Turtle","x":3,"y":10},{"type":"Turtle","x":7,"y":10},{"type":"Turtle","x":11,"y":10},{"type":"Turtle","x":3,"y":11},{"type":"Turtle","x":7,"y":11},{"type":"Turtle","x":10,"y":12},{"type":"GreenCar","x":12,"y":8},{"type":"GreenCar","x":8,"y":8},{"type":"GreenCar","x":3,"y":8},{"type":"YellowCar","x":10,"y":9},{"type":"YellowCar","x":5,"y":9},{"type":"YellowCar","x":0,"y":9},{"type":"Turtle","x":13,"y":12},{"type":"Turtle","x":0,"y":11},{"type":"LogEnd","x":14,"y":2},{"type":"LogMiddle","x":13,"y":2},{"type":"LogStart","x":12,"y":2},{"type":"Turtle","x":13,"y":1},{"type":"Turtle","x":9,"y":1},{"type":"Turtle","x":5,"y":1},{"type":"Turtle","x":1,"y":1},{"type":"Turtle","x":1,"y":13},{"type":"Froggy6","x":8,"y":14}],"spawn":{"x":7,"y":7,"dir":0},"time":15000} \ No newline at end of file +{"cols":15,"rows":15,"tileInfoKeys":["Grass","Water","Road","Bush","Lilypad"],"tiles":[1,1,0,0,0,0,3,0,3,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,4,1,1,1,4,1,1,1,4,1,1,0,0,0,1,1,1,4,1,1,1,4,1,1,1,1,0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,3,1,1,1,1,3,0,3,1,1,1,1,3,0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,1,1,1,1,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,1,1,4,1,1,3,1,4,1,0,0,3,0,0,3,0,1,1,4,1,0,4,1,1,0,0,0,0,3,0,0,0,1,1,1,1,1,1,0,3,0,0,3,0,0,3,0,1,1,1,1],"directions":[0,1,0,0,0,0,0,0,0,0,0,0,0,3,0,0,1,2,2,2,2,2,2,2,2,2,2,2,2,0,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,3,2,2,2,2,2,2,2,2,2,2,2,0,0,2,2,4,4,4,4,4,4,4,4,3,0,1,2,0,0,0,1,0,0,0,0,0,0,0,4,3,0,1,0,0,4,1,0,0,0,0,0,0,0,0,3,0,1,2,0,1,0,0,0,0,0,0,0,0,0,3,0,0,0],"entities":[{"type":"BlueCar","x":3,"y":6,"dir":4},{"type":"BlueCar","x":9,"y":6,"dir":4},{"type":"Froggy1","x":3,"y":0,"dir":3},{"type":"Froggy2","x":10,"y":0,"dir":3},{"type":"Froggy3","x":0,"y":7,"dir":4},{"type":"Froggy4","x":14,"y":7,"dir":2},{"type":"Froggy5","x":4,"y":13,"dir":1},{"type":"RedCar","x":12,"y":5,"dir":4},{"type":"RedCar","x":6,"y":5,"dir":4},{"type":"RedCar","x":1,"y":5,"dir":4},{"type":"LogMiddle","x":2,"y":2,"dir":4},{"type":"LogStart","x":7,"y":2,"dir":4},{"type":"LogStart","x":1,"y":2,"dir":4},{"type":"LogEnd","x":3,"y":2,"dir":4},{"type":"LogEnd","x":8,"y":2,"dir":4},{"type":"Turtle","x":3,"y":10,"dir":2},{"type":"Turtle","x":7,"y":10,"dir":2},{"type":"Turtle","x":11,"y":10,"dir":2},{"type":"Turtle","x":3,"y":11,"dir":4},{"type":"Turtle","x":7,"y":11,"dir":4},{"type":"Turtle","x":10,"y":12,"dir":4},{"type":"GreenCar","x":12,"y":8,"dir":2},{"type":"GreenCar","x":8,"y":8,"dir":2},{"type":"GreenCar","x":3,"y":8,"dir":2},{"type":"YellowCar","x":10,"y":9,"dir":2},{"type":"YellowCar","x":5,"y":9,"dir":2},{"type":"YellowCar","x":0,"y":9,"dir":2},{"type":"Turtle","x":0,"y":11,"dir":2},{"type":"LogEnd","x":14,"y":2,"dir":4},{"type":"LogMiddle","x":13,"y":2,"dir":4},{"type":"LogStart","x":12,"y":2,"dir":4},{"type":"Turtle","x":13,"y":1,"dir":2},{"type":"Turtle","x":9,"y":1,"dir":2},{"type":"Turtle","x":5,"y":1,"dir":2},{"type":"Turtle","x":1,"y":1,"dir":1},{"type":"Turtle","x":1,"y":13,"dir":4},{"type":"Froggy6","x":8,"y":14,"dir":1},{"type":"Turtle","x":13,"y":12,"dir":1}],"spawn":{"x":7,"y":7,"dir":1},"time":15000} \ No newline at end of file diff --git a/games/Frogger/src/Entity.js b/games/Frogger/src/Entity.js index 7407604..be00cbc 100644 --- a/games/Frogger/src/Entity.js +++ b/games/Frogger/src/Entity.js @@ -13,18 +13,53 @@ export const Dir = [ /*Right:*/ { x: 1, y: 0, angle: 0 , dist: ( x, y ) => Math.floor( x + 1 ) - x }, ]; -export class Entity { - static draw( entity, ctx, { dir, action, time } = {} ) { - ctx.save(); - ctx.translate( entity.x, entity.y ); - ctx.rotate( Dir[ ( dir > 0 ? dir : null ) ?? entity.dir ]?.angle ?? 0 ); - // ctx.scale( entity.size, entity.size ); // nothing changes size for now - - ctx.strokeStyle = 'black'; - ctx.lineWidth = 0.02; - - Entities[ entity.type ].draw( ctx, action ?? entity.animationAction, time ?? entity.animationTime ?? 0 ); +export const Rasterized = {}; + +// TODO: Don't use {} for parameter here (save heap?) + +// NOTE: Using 1.5 to give extra space for log center, animated frog legs, etc +const SIZE = 1.5; + +export function draw( entity, ctx, { dir, action, time } = {} ) { + let rasterized = Rasterized[ entity.type ]; + + if ( !rasterized ) { + const image = new OffscreenCanvas( SIZE * ctx.scaleVal * devicePixelRatio, SIZE * ctx.scaleVal * devicePixelRatio ); + const offscreenCtx = image.getContext( '2d' ); + + offscreenCtx.scale( image.width / SIZE, image.height / SIZE ); + offscreenCtx.translate( SIZE / 2, SIZE / 2 ); + + offscreenCtx.strokeStyle = 'black'; + offscreenCtx.lineWidth = 0.02; + + rasterized = Rasterized[ entity.type ] = { + image: image, + ctx: offscreenCtx, + needsRedraw: true, + } + } + + if ( rasterized.needsRedraw ) { + rasterized.ctx.clearRect( -SIZE / 2, -SIZE / 2, SIZE, SIZE ); + + Entities[ entity.type ].draw( rasterized.ctx, action ?? entity.animationAction, time ?? entity.animationTime ?? 0 ); + + rasterized.needsRedraw = false; + } + + const rotate = Dir[ ( dir > 0 ? dir : null ) ?? entity.dir ]?.angle ?? 0; - ctx.restore(); + ctx.translate( entity.x, entity.y ); + ctx.rotate( rotate ); + ctx.translate( -SIZE / 2, -SIZE / 2 ); + ctx.scale( SIZE / rasterized.image.width, SIZE / rasterized.image.height ); + { + ctx.drawImage( Rasterized[ entity.type ].image, 0, 0 ); } + ctx.scale( rasterized.image.width / SIZE, rasterized.image.height / SIZE ); + ctx.translate( SIZE / 2, SIZE / 2 ); + ctx.rotate( -rotate ); + ctx.translate( -entity.x, -entity.y ); + } diff --git a/games/Frogger/src/Frog.js b/games/Frogger/src/Frog.js index 9dc8cc8..ed33fad 100644 --- a/games/Frogger/src/Frog.js +++ b/games/Frogger/src/Frog.js @@ -29,15 +29,6 @@ pupils.ellipse( PUPIL_OFFSET_X, -PUPIL_OFFSET_Y, PUPIL_W, PUPIL_H, 0, 0, Math.PI pupils.moveTo( PUPIL_OFFSET_X, PUPIL_OFFSET_Y + PUPIL_H ); pupils.ellipse( PUPIL_OFFSET_X, PUPIL_OFFSET_Y, PUPIL_W, PUPIL_H, 0, 0, Math.PI * 2 ); -const exes = new Path2D(); -exes.moveTo( EYE_OFFSET_X - EYE_SIZE, -EYE_OFFSET_Y - EYE_SIZE ); -exes.lineTo( EYE_OFFSET_X + EYE_SIZE, -EYE_OFFSET_Y + EYE_SIZE ); -exes.moveTo( EYE_OFFSET_X + EYE_SIZE, -EYE_OFFSET_Y - EYE_SIZE ); -exes.lineTo( EYE_OFFSET_X - EYE_SIZE, -EYE_OFFSET_Y + EYE_SIZE ); -exes.moveTo( EYE_OFFSET_X - EYE_SIZE, EYE_OFFSET_Y - EYE_SIZE ); -exes.lineTo( EYE_OFFSET_X + EYE_SIZE, EYE_OFFSET_Y + EYE_SIZE ); -exes.moveTo( EYE_OFFSET_X + EYE_SIZE, EYE_OFFSET_Y - EYE_SIZE ); -exes.lineTo( EYE_OFFSET_X - EYE_SIZE, EYE_OFFSET_Y + EYE_SIZE ); export class Frog { static Status = { @@ -121,9 +112,29 @@ export class Frog { } if ( animationAction && animationAction != Frog.Status.Alive ) { - ctx.strokeStyle = 'black'; - ctx.lineWidth = EYE_SIZE / 2; - ctx.stroke( exes ); + ctx.fillStyle = 'black'; + + ctx.translate( EYE_OFFSET_X, -EYE_OFFSET_Y ); + ctx.scale( EYE_SIZE, EYE_SIZE ); { + ctx.rotate( Math.PI / 4 ); + ctx.fillRect( -1, -1 / 4, 2, 1 / 2 ); + ctx.rotate( Math.PI / 2 ); + ctx.fillRect( -1, -1 / 4, 2, 1 / 2 ); + ctx.rotate( -3 * Math.PI / 4 ); + } + ctx.scale( 1 / EYE_SIZE, 1 / EYE_SIZE ); + ctx.translate( -EYE_OFFSET_X, EYE_OFFSET_Y ); + + ctx.translate( EYE_OFFSET_X, EYE_OFFSET_Y ); + ctx.scale( EYE_SIZE, EYE_SIZE ); { + ctx.rotate( Math.PI / 4 ); + ctx.fillRect( -1, -1 / 4, 2, 1 / 2 ); + ctx.rotate( Math.PI / 2 ); + ctx.fillRect( -1, -1 / 4, 2, 1 / 2 ); + ctx.rotate( -3 * Math.PI / 4 ); + } + ctx.scale( 1 / EYE_SIZE, 1 / EYE_SIZE ); + ctx.translate( -EYE_OFFSET_X, -EYE_OFFSET_Y ); } else { ctx.fillStyle = 'white'; @@ -133,12 +144,5 @@ export class Frog { ctx.fillStyle = 'black'; ctx.fill( pupils ); } - - // TODO: This rectangle is obvious near edges of water - // Maybe change the colors instead of drawing a rectangle? - if ( animationAction == Frog.Status.Drowned ) { - ctx.fillStyle = '#000080aa'; - ctx.fillRect( -0.5, -0.5, 1, 1 ); - } } } diff --git a/games/Frogger/src/FroggerCanvas.js b/games/Frogger/src/FroggerCanvas.js index 76f6172..fe936c0 100644 --- a/games/Frogger/src/FroggerCanvas.js +++ b/games/Frogger/src/FroggerCanvas.js @@ -18,7 +18,6 @@ const KeyDir = { export class FroggerCanvas extends AnimatedCanvas { showUI = true; - scale = 1; world; @@ -62,9 +61,6 @@ export class FroggerCanvas extends AnimatedCanvas { } draw( ctx ) { - ctx.save(); - ctx.scale( this.scale, this.scale ); - this.world?.draw( ctx, this.showUI ); } } \ No newline at end of file diff --git a/games/Frogger/src/Froggy.js b/games/Frogger/src/Froggy.js index cca31bd..83041a2 100644 --- a/games/Frogger/src/Froggy.js +++ b/games/Frogger/src/Froggy.js @@ -15,12 +15,12 @@ export class Froggy extends Frog { } static drawFroggy( ctx, colorIndex ) { - ctx.save(); ctx.scale( Froggy.Size, Froggy.Size ); bodyGrad[ colorIndex ] ??= Frog.getFrogGradient( ctx, colors[ colorIndex ] ); Frog.drawFrog( ctx, bodyGrad[ colorIndex ] ); - ctx.restore(); + + ctx.scale( 1 / Froggy.Size, 1 / Froggy.Size ); } } diff --git a/games/Frogger/src/Level.js b/games/Frogger/src/Level.js index a23483c..70acf28 100644 --- a/games/Frogger/src/Level.js +++ b/games/Frogger/src/Level.js @@ -1,5 +1,4 @@ import { Tiles } from './Tiles.js'; -import { Props } from './Props.js'; import { TileMap } from './TileMap.js'; export class Level @@ -118,28 +117,5 @@ export class Level } this.#tileMap.draw( ctx ); - - ctx.save(); - - for ( let row = 0; row < this.rows; row ++ ) { - ctx.save(); - - for ( let col = 0; col < this.cols; col ++ ) { - - const prop = Props[ this.tileInfoKeys[ this.tiles[ col + row * this.cols ] ] ]; - prop?.draw( ctx ); - - if ( this.spawn.x == col && this.spawn.y == row ) { - Props[ 'Bullseye' ].draw( ctx ); - } - - ctx.translate( 1, 0 ); - } - - ctx.restore(); - ctx.translate( 0, 1 ); - } - - ctx.restore(); } } diff --git a/games/Frogger/src/TileMap.js b/games/Frogger/src/TileMap.js index d295b50..cb42ed4 100644 --- a/games/Frogger/src/TileMap.js +++ b/games/Frogger/src/TileMap.js @@ -1,4 +1,5 @@ import { Direction } from '../src/Entity.js'; +import { Props } from './Props.js'; import * as Utility from '../src/common/Utility.js'; const DirIndex = { @@ -15,12 +16,16 @@ const LAYERS = [ ]; const COLORS = [ - 'darkblue', + '#00aa', '#333', 'dimgray', 'green', ]; +export const Rasterized = { + image: null, +} + export class TileMap { #layerPaths; #layerEdges = []; @@ -29,6 +34,10 @@ export class TileMap { #lanesPath = new Path2D(); constructor( level ) { + this.level = level; + + Rasterized.image = null; + this.#layerPaths = LAYERS.map( layerName => { // Store the edges coming from each corner, indexed by direction const edges = Array.from( @@ -181,15 +190,50 @@ export class TileMap { } draw( ctx ) { - LAYERS.forEach( ( _, index ) => { - ctx.fillStyle = COLORS[ index ]; - ctx.fill( this.#layerPaths[ index ], 'evenodd' ); - } ); + if ( !Rasterized.image ) { + Rasterized.image = new OffscreenCanvas( this.level.cols * ctx.scaleVal * devicePixelRatio, this.level.rows * ctx.scaleVal * devicePixelRatio ); + const offscreenCtx = Rasterized.image.getContext( '2d' ); + + offscreenCtx.scale( ctx.scaleVal * devicePixelRatio, ctx.scaleVal * devicePixelRatio ); + offscreenCtx.translate( 0.5, 0.5 ); + + LAYERS.forEach( ( _, index ) => { + offscreenCtx.fillStyle = COLORS[ index ]; + offscreenCtx.fill( this.#layerPaths[ index ], 'evenodd' ); + } ); + + offscreenCtx.fillStyle = 'yellow'; + offscreenCtx.fill( this.#lanesPath ); + + offscreenCtx.fillStyle = 'gray'; + offscreenCtx.fill( this.#sidewalkSquares ); + + offscreenCtx.save(); { + for ( let row = 0; row < this.level.rows; row ++ ) { + offscreenCtx.save(); { + for ( let col = 0; col < this.level.cols; col ++ ) { + const prop = Props[ this.level.tileInfoKeys[ this.level.tiles[ col + row * this.level.cols ] ] ]; + prop?.draw( offscreenCtx ); + + if ( this.level.spawn.x == col && this.level.spawn.y == row ) { + Props[ 'Bullseye' ].draw( offscreenCtx ); + } - ctx.fillStyle = 'yellow'; - ctx.fill( this.#lanesPath ); + offscreenCtx.translate( 1, 0 ); + } + } + offscreenCtx.restore(); + offscreenCtx.translate( 0, 1 ); + } + } + offscreenCtx.restore(); + } - ctx.fillStyle = 'gray'; - ctx.fill( this.#sidewalkSquares ); + ctx.translate( -0.5, -0.5 ); + ctx.scale( 1 / ( ctx.scaleVal * devicePixelRatio ), 1 / ( ctx.scaleVal * devicePixelRatio ) ); { + ctx.drawImage( Rasterized.image, 0, 0 ); + } + ctx.scale( ctx.scaleVal * devicePixelRatio, ctx.scaleVal * devicePixelRatio ); + ctx.translate( 0.5, 0.5 ); } } diff --git a/games/Frogger/src/World.js b/games/Frogger/src/World.js index 13c3f54..09b0bb4 100644 --- a/games/Frogger/src/World.js +++ b/games/Frogger/src/World.js @@ -1,6 +1,6 @@ import { Dir } from './Entity.js'; import { Entities } from './Entities.js'; -import { Entity } from './Entity.js'; +import * as Entity from './Entity.js'; import { Frog } from './Frog.js'; import { Froggy } from './Froggy.js'; import { Player } from './Player.js'; @@ -9,6 +9,8 @@ import { Constants } from './Constants.js'; let animationTime = 0; +let timerGrad; + export class World { entities = []; @@ -284,79 +286,85 @@ export class World } draw( ctx, showUI = true ) { - ctx.save(); { - ctx.translate( 0.5, 0.5 ); - ctx.lineWidth = 0.02; + ctx.translate( 0.5, 0.5 ); + ctx.lineWidth = 0.02; - this.#level.draw( ctx ); + // TODO: Do we need this? Double-tracking animationAction and status, it seems... + if ( this.player ) { + this.player.animationAction = this.player.status; + } - // TODO: If drowned, draw below level (with translucent water) - - if ( this.player ) { - this.player.animationAction = this.player.status; + if ( this.player?.status == Frog.Status.Drowned ) { + Entity.draw( this.player, ctx ); + } - if ( this.player.status != Frog.Status.Alive ) { - Entity.draw( this.player, ctx ); - } - } + this.#level.draw( ctx ); + + if ( this.player?.status == Frog.Status.Expired || + this.player?.status == Frog.Status.SquishedHorizontal || + this.player?.status == Frog.Status.SquishedVertical ) { + Entity.draw( this.player, ctx ); + } - this.entities.forEach( entity => Entity.draw( entity, ctx, { time: animationTime } ) ); + this.entities.forEach( entity => Entity.draw( entity, ctx, { time: animationTime } ) ); - if ( this.player && this.player.status == Frog.Status.Alive ) { - Entity.draw( this.player, ctx ); - } + if ( this.player?.status == Frog.Status.Alive ) { + Entity.draw( this.player, ctx ); } - ctx.restore(); - // Victory/defeat banner - if ( this.defeat ) drawBanner( ctx, 'Defeat!' ); - if ( this.victory ) drawBanner( ctx, 'Victory!' ); + if ( Entity.Rasterized.Player ) { + Entity.Rasterized.Player.needsRedraw = true; + } + if ( Entity.Rasterized.Turtle ) { + Entity.Rasterized.Turtle.needsRedraw = true; + } // UI if ( showUI ) { - ctx.save(); { - ctx.translate( 0, 15 ); - ctx.fillStyle = 'gray'; - ctx.fillRect( 0, 0, 15, 1 ); - - ctx.translate( 0.5, 0.5 ); - ctx.lineWidth = 0.02; - - // Froggies - for ( let i = 0; i < Constants.NumFroggies; i ++ ) { - if ( this.rescued.includes( i ) ) { - ctx.save(); - ctx.rotate( Math.PI / 2 ); - Froggy.drawFroggy( ctx, i ); - ctx.restore(); - } - ctx.translate( 1, 0 ); + ctx.translate( 0, 15 ); + ctx.fillStyle = 'gray'; + ctx.strokeStyle = 'black'; + ctx.fillRect( -0.5, -0.5, 15, 1 ); + + // Froggies + for ( let i = 0; i < Constants.NumFroggies; i ++ ) { + if ( this.rescued.includes( i ) ) { + ctx.rotate( Math.PI / 2 ); + Froggy.drawFroggy( ctx, i ); + ctx.rotate( -Math.PI / 2 ); } - - // Timer - const timerGrad = ctx.createLinearGradient( 0, 0, 3, 0 ); + ctx.translate( 1, 0 ); + } + + // Timer + if ( !timerGrad ) { + timerGrad = ctx.createLinearGradient( 0, 0, 3, 0 ); timerGrad.addColorStop( 0, 'red' ); timerGrad.addColorStop( 0.5, 'yellow' ); timerGrad.addColorStop( 1, 'green' ); + } - ctx.fillStyle = timerGrad; - ctx.fillRect( 0, -0.15, 4 * ( this.timeLeft / this.#level.time ), 0.3 ); - ctx.strokeRect( 0, -0.15, 4, 0.3 ); + ctx.fillStyle = timerGrad; + ctx.fillRect( 0, -0.15, 4 * ( this.timeLeft / this.#level.time ), 0.3 ); + ctx.strokeRect( 0, -0.15, 4, 0.3 ); - ctx.translate( 5, 0 ); + ctx.translate( 5, 0 ); - // Lives - for ( let i = 4; i > 0; i -- ) { - if ( i <= this.lives ) { - ctx.save(); - ctx.rotate( -Math.PI / 2 ); - Player.drawPlayer( ctx ); - ctx.restore(); - } - ctx.translate( 1, 0 ); + // Lives + for ( let i = 4; i > 0; i -- ) { + if ( i <= this.lives ) { + ctx.rotate( -Math.PI / 2 ); + Player.drawPlayer( ctx ); + ctx.rotate( Math.PI / 2 ); } + ctx.translate( 1, 0 ); } - ctx.restore(); + + ctx.translate( -15.5, -15.5 ); + + // Victory/defeat banner + if ( this.defeat ) drawBanner( ctx, 'Defeat!' ); + if ( this.victory ) drawBanner( ctx, 'Victory!' ); } if ( this.paused ) { @@ -373,19 +381,15 @@ export class World } function drawBanner( ctx, text ) { - ctx.save(); { - ctx.fillStyle = '#000b'; - ctx.fillRect( 0, 6.5, 15, 2 ); - - ctx.strokeStyle = 'white'; - ctx.lineWidth = 0.05; - ctx.strokeRect( -1, 6.5, 17, 2 ); - - // TODO: Can text be part of a Path2D? Does that help anything? - ctx.textAlign = 'center'; - ctx.font = '1px Silly'; - ctx.fillStyle = 'white'; - ctx.fillText( text, 15 / 2, 7.8 ); - } - ctx.restore(); + ctx.fillStyle = '#000b'; + ctx.fillRect( 0, 6.5, 15, 2 ); + + ctx.strokeStyle = 'white'; + ctx.lineWidth = 0.05; + ctx.strokeRect( -1, 6.5, 17, 2 ); + + ctx.textAlign = 'center'; + ctx.font = '1px Silly'; + ctx.fillStyle = 'white'; + ctx.fillText( text, 15 / 2, 7.8 ); } diff --git a/games/Frogger/src/common/AnimatedCanvas.js b/games/Frogger/src/common/AnimatedCanvas.js index 02505dd..a368cbd 100644 --- a/games/Frogger/src/common/AnimatedCanvas.js +++ b/games/Frogger/src/common/AnimatedCanvas.js @@ -1,6 +1,10 @@ export class AnimatedCanvas { + ShowFPS = true; + #reqId; + #frameRates = []; + constructor( width, height, canvas ) { this.canvas = canvas ?? document.createElement( 'canvas' ); this.canvas.oncontextmenu = () => { return false }; @@ -10,6 +14,7 @@ export class AnimatedCanvas { } this.ctx = this.canvas.getContext( '2d' /*, { alpha: false }*/ ); + this.ctx.scaleVal = 1; if ( width && height ) { this.setSize( width, height ); @@ -25,17 +30,14 @@ export class AnimatedCanvas { this.canvas.height = height * devicePixelRatio; this.canvas.style.width = width + 'px'; this.canvas.style.height = height + 'px'; - - this.ctx.scale( devicePixelRatio, devicePixelRatio ); } redraw() { // Don't need this because we're drawing the level over everything - // this.ctx.clearRect( 0, 0, this.ctx.canvas.width, this.ctx.canvas.height ); + this.ctx.clearRect( 0, 0, this.ctx.canvas.width, this.ctx.canvas.height ); - this.ctx.save(); + this.ctx.setTransform( this.ctx.scaleVal * devicePixelRatio, 0, 0, this.ctx.scaleVal * devicePixelRatio, 0, 0 ); this.draw( this.ctx ); - this.ctx.restore(); } // TODO: Handle starts if already started, stops if already stopped... @@ -45,10 +47,41 @@ export class AnimatedCanvas { let lastTime; const animate = ( now ) => { lastTime ??= now; // for first call only - this.update( now - lastTime ); + const dt = now - lastTime; lastTime = now; - + + this.update( dt ); this.redraw(); + + if ( this.ShowFPS ) { + this.#frameRates.push( 1000 / dt ); + if ( this.#frameRates.length > 60 ) { + this.#frameRates.shift(); + } + + this.ctx.setTransform( devicePixelRatio, 0, 0, devicePixelRatio, 0, 0 ); + this.ctx.lineWidth = 1; + + // this.ctx.save(); { + this.ctx.beginPath(); + + this.ctx.rect( 0, 0, 60, 70 ); + for ( let y = 10; y < 70; y += 10 ) { + this.ctx.moveTo( 0, y ); + this.ctx.lineTo( 60, y ); + } + + this.ctx.strokeStyle = 'yellow'; + this.ctx.stroke(); + + this.ctx.beginPath(); + this.#frameRates.forEach( ( rate, index ) => this.ctx.lineTo( index, 70 - rate ) ); + + this.ctx.strokeStyle = 'orange'; + this.ctx.stroke(); + // } + // this.ctx.restore(); + } this.#reqId = requestAnimationFrame( animate ); }; diff --git a/games/Frogger/src/entities/Turtle.js b/games/Frogger/src/entities/Turtle.js index 1be2503..d375268 100644 --- a/games/Frogger/src/entities/Turtle.js +++ b/games/Frogger/src/entities/Turtle.js @@ -47,45 +47,46 @@ export class Turtle { bodyGrad.addColorStop( 1, 'black' ); } - ctx.translate( -0.05, 0 ); - - ctx.fillStyle = bodyGrad; - - const legAngleOffset = 0.3 * Math.sin( 0.005 * animationTime ); + ctx.translate( -0.05, 0 ); { + ctx.fillStyle = bodyGrad; + + const legAngleOffset = 0.3 * Math.sin( 0.005 * animationTime ); + + ctx.beginPath(); + + [ -1, 1 ].forEach( side => { + [ 0.4, 0.75 ].forEach( angle => { + const rotate = side * ( Math.PI * angle + legAngleOffset ); + ctx.rotate( rotate ); { + ctx.moveTo( 0, 0 ); + ctx.lineTo( LEG_L, -LEG_W ); + ctx.lineTo( LEG_L, LEG_W ); + } + ctx.rotate( -rotate ); + } ); + } ); - ctx.beginPath(); + ctx.moveTo( SHELL_SIZE + HEAD_SIZE * 1.5, 0 ); + ctx.arc( SHELL_SIZE + HEAD_SIZE / 2, 0, HEAD_SIZE, 0, Math.PI * 2 ); - [ -1, 1 ].forEach( side => { - [ 0.4, 0.75 ].forEach( angle => { - ctx.save(); - ctx.rotate( side * ( Math.PI * angle + legAngleOffset ) ); + ctx.fill(); + ctx.stroke(); - ctx.moveTo( 0, 0 ); - ctx.lineTo( LEG_L, -LEG_W ); - ctx.lineTo( LEG_L, LEG_W ); + if ( !shellGrad ) { + shellGrad = ctx.createRadialGradient( 0, 0, 0, 0, 0, 1.5 ); + shellGrad.addColorStop( 0, 'darkolivegreen' ); + shellGrad.addColorStop( 0.5, 'black' ); + } - ctx.restore(); - } ); - } ); + ctx.fillStyle = shellGrad; - ctx.moveTo( SHELL_SIZE + HEAD_SIZE * 1.5, 0 ); - ctx.arc( SHELL_SIZE + HEAD_SIZE / 2, 0, HEAD_SIZE, 0, Math.PI * 2 ); + ctx.fill( shell ); + ctx.stroke( shell ); - ctx.fill(); - ctx.stroke(); + ctx.strokeStyle = '#000a'; + ctx.stroke( detail ); - if ( !shellGrad ) { - shellGrad = ctx.createRadialGradient( 0, 0, 0, 0, 0, 1.5 ); - shellGrad.addColorStop( 0, 'darkolivegreen' ); - shellGrad.addColorStop( 0.5, 'black' ); } - - ctx.fillStyle = shellGrad; - - ctx.fill( shell ); - ctx.stroke( shell ); - - ctx.strokeStyle = '#000a'; - ctx.stroke( detail ); + ctx.translate( 0.05, 0 ); } } \ No newline at end of file diff --git a/games/Frogger/test/drawTest.html b/games/Frogger/test/drawTest.html index 4e8fc82..f8d1101 100644 --- a/games/Frogger/test/drawTest.html +++ b/games/Frogger/test/drawTest.html @@ -9,12 +9,14 @@ const entities = Array.from( Array( 1000 ), _ => ( { x: Math.floor( Math.random() * 100 ), y: Math.floor( Math.random() * 100 ), - angle: Math.random() * 6 + angle: Math.random() * 360 + // angle: Math.random() * 6 } ) ); canvas.update = ( dt ) => { entities.forEach( e => { - e.angle += dt / 1000; + // e.angle += dt / 1000; + e.angle += dt / 10; } ); } @@ -28,18 +30,25 @@ ctx.save(); ctx.scale( 10, 10 ); - ctx.beginPath(); + // ctx.beginPath(); + const combined = new Path2D(); entities.forEach( e => { - ctx.save(); + // ctx.save(); - ctx.translate( e.x, e.y ); - ctx.rotate( e.angle ); + // ctx.translate( e.x, e.y ); + // ctx.rotate( e.angle ); + + let transform = new DOMMatrix(); + transform.translateSelf( e.x, e.y ); + transform.rotateSelf( e.angle ); + + combined.addPath( path, transform ); // ctx.beginPath(); // ctx.moveTo( 0, 0 ); - ctx.rect( -2, -1, 4, 2 ); + // ctx.rect( -2, -1, 4, 2 ); // ctx.lineTo( -2, -1 ); // ctx.lineTo( 2, -1 ); @@ -59,11 +68,12 @@ // ctx.fill(); - ctx.restore(); + // ctx.restore(); } ); - // ctx.fill(); - ctx.stroke(); + ctx.fill( combined ); + // ctx.fill( ); + // ctx.stroke(); ctx.restore(); }; diff --git a/games/Frogger/test/loadLevel.html b/games/Frogger/test/loadLevel.html index 85bbbab..3c54c5b 100644 --- a/games/Frogger/test/loadLevel.html +++ b/games/Frogger/test/loadLevel.html @@ -9,23 +9,19 @@ Level.DebugGrid = true; - const level = Object.assign( new Level(), await ( await fetch( '../levels/classic/river.json' ) ).json() ); + const level = Object.assign( new Level(), await ( await fetch( '../levels/classic/retro.json' ) ).json() ); const world = new World( level ); const canvas = new AnimatedCanvas(); + canvas.ctx.scaleVal = 48; canvas.update = ( dt ) => { world.update( dt ); } canvas.draw = ( ctx ) => { - ctx.save(); - ctx.scale( 48, 48 ); - world.draw( ctx ); - - ctx.restore(); }; canvas.start(); diff --git a/games/Frogger/ui.css b/games/Frogger/ui.css index d74ab60..a630a73 100644 --- a/games/Frogger/ui.css +++ b/games/Frogger/ui.css @@ -10,6 +10,7 @@ body { overscroll-behavior: none; /* Disable Chrome two fingers back/forward swipe */ user-select: none; /* Prevent accidental selection while dragging mouse */ + -webkit-user-select: none; /* ditto, for iOS */ touch-action: none; background-color: #222222;