0.0.1 • Published 3 months ago

p5.raycaster v0.0.1

Weekly downloads
-
License
GPL-3.0-or-later
Repository
github
Last release
3 months ago

p5.RayCaster

A simple library for p5.js to make semi-3D scene or game with ray casting. Made by JohnC

Based on algorithms from lodev and the demo made by Andrew Mushel

If you are not familiar with what ray casting is, read lodev's blog which explain it really well.

If you made anything interesting with this library, let me know!

Installation

include dist/p5.RayCaster.min.js in your project

...
<head>
    ...
    <script src="path/tp/p5.js">
    <script src="path/to/p5.RayCaster.min.js">
    <script src="sketch.js">
    ...
</head>
...

This will provide the RayCaster module.

API

click here

Example

How to use

Basic concept

There are four basic component in p5.RayCaster library, World, Camera, and Sprite

World

World class apis: click here

World is the type of objects that store information about your scene. You can think about it as a game world, the major properties it has include map, table, skyBok, textureMap, sprites, and cameras.

To create a World object, use RayCaster.createWorld()

RayCaster.createWorld(24,24); // return a World object with a width of 24 block and a height of 24 block with default properties
RayCaster.createWorld(width, height, mapData, textureMap, skyBox, typeTable, options); // return a World object with customized properties, see below sections for each of the items

table

World.table is an object storing information about different type of block in the world, by default it is:

defaultTypeTable = {
    MAP_FLOOR: 0, // floor means no wall or other type of blocks
    MAP_WALL: 1, // wall 
    MAP_WALL_SHADOW: 2, // for rendering shadow overlay on wall, should not be used in map
    MAP_DOOR: 3, // door
    MAP_DOOR_FRAME: 4, // door frame or an open door
    MAP_PUSH_WALL: 5, // wall that can be destroyed, often used to create hidden room
    MAP_CIRCULAR_COLUMN: 6, // round pillar
    MAP_DIA_WALL_TR_BL: 7, // diagonal walls from top right to bottom left of the block
    MAP_DIA_WALL_TL_BR: 8, // diagonal walls from top left to bottom right of the block
    MAP_TRANSPARENT_WALL: 9, // transparent wall (ray can go past it)
    DOOR_CLOSED: 0, // for recoding door state
    DOOR_OPENING: 1, // for recoding door state
    DOOR_OPEN: 2, // for recoding door state
    DOOR_CLOSING: 3, // for recoding door state
}

It is highly recommended to design your map according to this default table instead of modifying it. However you can do that by passing your typeTable in RayCaster.createWorld(). World.table is used as an reference for Camera.miniMapOptions, so if you used a type table other then the default one you need to update the Camera.miniMapOptions too.

map

World.map is a flat array storing the type of each block in the world, referencing World.table. Each element of the array is a number representing the type of the block in the world.

You can load a map with loadMap() after creating a world or pass the map data in RayCaster.createWorld().

var world = RayCaster.createWorld(24,24);
world.loadMap(mapData);

mapData can be a string of number separated by comma, like "1,1,1,1,1,0,...", a flat array of number [1,1,1,1,1,0,...] or a 2D array of number which will be more readable, for example:

mapData = [
    [1,1,1,1,1,1],
    [1,0,0,0,0,1],
    [1,0,0,0,0,1],
    [1,1,1,1,1,1]
]

If mapData is a 2D array, the map defined by map data can be smaller or larger then the world's size, in which case the World will copy it from top left.

let mapData = [
    [1,1,1,1,1],
    [1,0,0,0,1],
    [1,0,0,0,1],
    [1,0,0,0,1],
    [1,1,1,1,1]
]
let world1 = RayCaster.createWorld(6,6,mapData);
/*
resulting in a map looks like this:
1,1,1,1,1,0
1,0,0,0,1,0
1,0,0,0,1,0
1,0,0,0,1,0
1,1,1,1,1,0
0,0,0,0,0,0
*/
let world2 = RayCaster.createWorld(4,4,mapData);
/*
resulting in a map looks like this:
1,1,1,1,
1,0,0,0,
1,0,0,0,
1,0,0,0,
*/

You can have different kinds of block of the same type, which allowed them to have different textures (we will cover that in the later textureMap section) and interaction. World find out what type the block is by looking up World.table with the result of the number of the block mod 10, for example, with the default type table, 1, 11, 21, 1111 will be recognized as MAP_WALL and 3,13,103 will be recognized as MAP_DOOR. Keep that in mind when coding your own type table. The mechanism is also useful for matching texture for a door in different states, for example, apply same (or related) texture to 13 and 14 will make the door block under kind 13 looks consistent when open and closed.

Below is an example map with the block kind number overlaying on it

map.png

floor and ceiling

You can have floor and ceiling for each block in the map too, they are stored in World.floor and World.ceiling in the same format as World.map. By default they are undefined. To load the floor and ceiling data, use World.loadFloor() and/or World.loadCeiling(). You should use numbers that are not in World.map to represent ceiling and floor texture, and include the corresponding entry in World.textureMap. A good way to do that is to use negative numbers for floor and ceiling textures.

Note that ray casting floor and ceiling are not as fast as ray casting wall.

textureMap

World.textureMap is a Map storing texture for each of the block. The entries of it are numbers that appear in World.map, with the corresponding value denoting the texture (either a css string or a p5.Image/p5.Graphics object);

You can use RayCaster.createTextureMap() to create a texture map. To use a texture map, pass it in RayCaster.createWorld()

let textureMap = RayCaster.createTextureMap(1, "red", 11, "pink", 21, "rgb(255,200,100)", 3, doorImage, );
RayCaster.createWorld(width, height, mapData, textureMap);

skyBox

World.skyBox is an object denoting how the background of your scene should be rendered. It has a structure like this:

{
    sky: a css color string or a p5.Image object,
    ground: a css color string or a p5.Image object,
    front: [optional]a p5.Image object or a p5.Graphics object,
    middle: [optional]a p5.Image object or a p5.Graphics object,
    back: [optional]a p5.Image object or a p5.Graphics object,
}

skyBox.sky is used to render the upper part of the sky box and skyBox.ground is used to render the lower part of the sky box (it's also the floor of the game world).

The optional skyBox.front, skyBox.middle, and skyBox.back are used to render parallax scrolling layers of background.

By default, the following sky box object is used when creating a world:

defaultSkyBox = {
    sky: "black",
    ground: "grey",
    front: null,
    middle: null,
    back: null,
}

You can use RayCaster.createSkyBox() to create a sky box object

RayCaster.createSkyBox(sky, ground); // return a sky box object that has no scrolling layers
RayCaster.createSkyBox(sky, ground, null, null, back); //return a sky box object that has only one scrolling layers
RayCaster.createSkyBox(sky, ground, front, middle, back); //return a sky box object that has three scrolling layers

To use a skyBox, pass it into RayCaster.createWorld() when creating a new world.

sprites

World.sprites is an array storing sprites in the world. For more information about what is a sprite, see the Sprite section.

You can use addSprite() to add a sprite to the world and removeSprite() to remove one.

world.addSprite(sprite); // add a Sprite object to the world
world.removeSprite(sprite); // remove a Sprite object
//or
world.removeSprite(0); //remove a sprite by index

cameras

World.cameras is an array storing the cameras attached to this world, see the Camera section for more information

other functions

See the API documentation for all functions in World class.

Camera

Camera class apis: click here

Camera object deal with the rendering of the scene. To create a camera, use RayCaster.createCamera().

canvas = createCanvas(width, height);
let world = RayCaster.createWorld(24, 24, mapData);
let cameraPosition = {x: 12, y: 12};
let cameraDirection = {x: 1, y: 0}; // this should be a normalized vector
let fov = 0.66; // adjust with your canvas aspect ratio
let camera = RayCaster.createCamera(cameraPosition, cameraDirection, fov, world, canvas); // create a camera in the middle of `world` facing right, with a field of view of 0.66 and rendering to the main canvas

For a camera to work, it need to be attached to a World, if not ready done so during initialization, you can use Camera.attachToWorld(world) to attach it to a World object, to remove it from the world it attached to, use Camera.removeFromWorld().

rendering

To render a frame, call Camera.renderFrame(). This will render the current view of the camera to the canvas it was assigned to, alternatively, you can render to another canvas or p5.Graphics object by passing it to the function.

If you want more control over when the different parts of the scene is rendered, you can call the following functions individually. Same as the renderFrame() function, a target canvas can be passed in. Please see the api documents for the details of each function.

Camera.renderSkyBox(); //render the sky box
Camera.renderSkyBox(true, false) // render sky only

// rendering ray casting floor and ceiling can be very slow in high res
Camera.renderFloorAndCeiling(); //render the ray casting floor and ceiling
Camera.renderFloorAndCeiling(true, false); //render the ray casting floor only
//

Camera.renderRayCasting(); // render walls and sprites
Camera.renderRayCasting(true); // render walls only

For the ray casting scene, the library will recognize the x surfaces of the block to be the "darker" side so it can be distinguished from y surfaces. The renderer will apply a transparent layer of shadow to the texture, which you can customize by setting the texture for correspondingMAP_WALL_SHADOW in World.textureMap. For example, if you want to set the shadow on block kind 11, set it by textureMap.set(12, texture). setting the shadow texture to something like rgba(255,255,255,0.5) will "invert" the "lighting" as x surfaces will be brighter with a white tint.

You can also render the 2D map with Camera.renderMiniMap() which takes 5 or 6 arguments.

Camera.renderMiniMap(size, canvasX, canvasY, renderWidth, renderHeight, [canvas]);
// size: {x, y} numbers of block to be rendered on the mini map.
// canvasX, canvasY: location of the mini map on canvas.
// renderWidth, renderHeight: size of the mini map on canvas.
// [canvas]: optional, alternative canvas to render to

Camera.renderMiniMap() will refer to Camera.miniMapOptions for style. By default it is:

miniMapOptions = {
    border: {
        stroke: "white",
        strokeWeight: 3,
    },
    background: {
        fill: "grey"
    },
    sprite: {
        fill: "purple",
        stroke: undefined,
        strokeWeight: 0,
        dia: .5
    },
    camera: {
        fill: "yellow",
        stroke: undefined,
        strokeWeight: 0,
        dia: .5
    },
    fov: {
        stroke: "black",
        strokeWeight: 1,
    },
    blocks: new Map([
        [0, {}],
        [1, { fill: "red" }],
        [3, { fill: "blue" }],
        [4, { fill: "blue" }],
        [5, { fill: "red", stroke: "blue", strokeWeight: 1}],
        [6, { fill: "cyan" }],
        [7, { stroke: "red", strokeWeight: 3 }],
        [8, { stroke: "red",  strokeWeight: 3}],
        [9, { fill: "rgba(255,0,0,0.25)" }]
    ]),
    MAP_FLOOR: {
        fill: undefined,
        stroke: undefined,
        strokeWeight: 0,
    },
    MAP_WALL: {
        fill: "red",
        stroke: undefined,
        strokeWeight: 0,
    },
    MAP_DOOR: {
        fill: "blue",
        stroke: undefined,
        strokeWeight: 0,
    },
    MAP_DOOR_FRAME: {
        fill: "black",
        stroke: "blue",
        strokeWeight: 2,
    },
    MAP_PUSH_WALL: {
        fill: "red",
        stroke: "blue",
        strokeWeight: 1,
    },
    MAP_CIRCULAR_COLUMN: {
        fill: "cyan",
        stroke: undefined,
        strokeWeight: 0,
    },
    MAP_DIA_WALL_TR_BL: {
        fill: undefined,
        stroke: "red",
        strokeWeight: 3,
    },
    MAP_DIA_WALL_TL_BR: {
        fill: undefined,
        stroke: "red",
        strokeWeight: 3,
    },
    MAP_TRANSPARENT_WALL: {
        fill: "rgba(255,0,0,0.25)",
        stroke: undefined,
        strokeWeight: 0,
    }
}

Mini map options is related to World.table. You can use Camera.setMiniMapRenderOptions() to load your customized options. For each item, provide css strings for fill, stroke and number for strokeWeight. For blocks, alternatively you can provide a icon property holding a p5.Image or p5.Graphics object. If a block number is not found in miniMapOptions.blocks, it falls back to the block type default down below.

Camera movement

To control the camera, use the following functions.

Use Camera.move({x, y}) to move the camera, it will take Camera.world as reference so a wall or closed door on the map will affect the movement.

Use Camera.rotate(angle) to rotate the camera to left and right, and Camera.tilt(angle) to tilt the camera up and down. Camera.tilt() will be limited by Camera.tiltingRange which can be updated by Camera.updateTiltingRange(min, max) (min should be a negative number describing the limit tilting down and max should be a positive number describing the limit tilting up). If you have a PointerLockControl enable, it will control the camera rotation and tilting directly, see Controls section for more details.

Use Camera.teleportTo() to move the camera directly to a point. It is useful for resetting a camera.

let resetPosition = {x,y}, resetFacingDirection = {x, y}
camera.teleportTo(resetPosition, resetFacingDirection);

You can also teleport to another world

newWorld = RayCaster.createWorld(24, 24, mapData);
camera.teleportTo(positionInNewWorld, facingDirectionInNewWorld, newWorld);

Map interaction

Camera class also has some functions for simple interaction with the world. If the camera is facing a closed door or a push wall, call Camera.openDoor() to open it. When facing a open door, call Camera.closeDoor() to close it. Use Camera.moveDoor() to check the state of the camera facing door and perform corresponding action.

Sprite

Sprite class apis: click here

Sprite is object for things that are "floating" in the world, which means their position can be changed. To create a Sprite, use RayCaster.createSprite().

function preload(){
    spriteImg = loadImage(path);
    //!you should not put the code to create sprite here as image might not be loaded
}
...
let spritePosition = {x: 11.5, y: 11.5}, spriteWidth = 100, spriteHeight = 100;
RayCaster.createSprite(spriteImg, spritePosition, spriteWidth, spriteHeight);

If you are using p5 in instance mode, you also need to pass in the p5 instance.

new p5((pInst) => {
    pInst.preload = (){
        spriteImg = pInst.loadImage(path)
    }

    pInst.setup = (){
        let spritePosition = {x: 11.5, y: 11.5}, spriteWidth = 100, spriteHeight = 100, angle = 0, yAdjustment = 0, animationGap = 0;
        RayCaster.createSprite(spriteImg, spritePosition, spriteWidth, spriteHeight, angle, yAdjustment, animationGap, pInst);
    }
})

The source image of the sprites should contain all the frames for animation and rotation(view from different angle), in the layout like this:

sprite.png

After creating a sprite, you can add it to a world by calling World.addSprite(sprite). A Sprite can't be in different World at the same time, before adding it to a World, check if Sprite.world exist, if so, call World.removeSprite(sprite) first.

Animation

To advance the sprite animation, call Sprite.nextAnimationFrame() or use Sprite.updateAnimationFrame(frameNo) to jump to a frame, note that frameNo start with 0. You can also set the Sprite.animationGap by Sprite.setAnimationGap() and update with Sprite.update(mainCanvasFrameCount) to update animation frame.

Advance animation

You can have animation group for your Sprite, so your Sprite can have different sets of animation switched by user interaction. By default, Sprite.animationGroups is an array contain an array of all animation frames from the Sprite.src, something like [[0,1,2,3,4]]. If the last two frames of the animation should be in another group, you can call Sprite.setAnimationGroups([[0,1,2], [3,4]]) to separate them into another group, then call Sprite.setCurrentAnimationGroup(1) to set the animation to the second one. This is useful for switching between idle and running animation, etc.

Movement

Similar to Camera, Sprite will refer to the world it is in to constrain the movement. use Sprite.move({x, y}) to move a sprite and Sprite.rotate(angle) to rotate a sprite. Use Sprite.setYAdjustment() to set or update Sprite.yAdjustment, which can move the sprite up and down.

Scale

You can use Sprite.scale() to scale a sprite, which will take the previous scale into account. To scale according to the original size, use Sprite.scaleTo(), you can scale the two axises of the sprite separately, e.g. Sprite.scale(0.5, 1).

Controls

The library includes three basic controllers to handle inputs from mouse and keyboard. They should be enough for most of the cases, however, if you wish to have a more innovative and different way of interaction, you should write your own control methods.

MouseControl

MouseControl object provides a simple interface to read mouse position and mouse button. To create a MouseControl object, use RayCaster.initMouseControl(targetElement).

let canvas = createCanvas(width, height);
let mouseController = RayCaster.initMouseControl(canvas.canvas);
// this will create a mouse controller tarting the main canvas

There are numbers of automatically updated properties in MouseControl object useful for interaction.

MouseControl.mouseIsDown;// boolean, true when a mouse button is pressed
MouseControl.mouseButton;// number indicating the last pressed mouse button : 0 primary button; 1 middle button; 2 secondary button
MouseControl.mouseX;// x coordinate of the cursor referencing the target element
MouseControl.mouseY;// y coordinate of the cursor referencing the target element
MouseControl.mouseCX;// x coordinate of the cursor referencing the browser window
MouseControl.mouseCY;// y coordinate of the cursor referencing the browser window
MouseControl.pmouseX;// previous value of x coordinate of the cursor referencing the target element
MouseControl.pmouseY;// previous value of y coordinate of the cursor referencing the target element
MouseControl.pmouseCX;// previous value of x coordinate of the cursor referencing the browser window
MouseControl.pmouseCY;// previous value of y coordinate of the cursor referencing the browser window

To disable the controller, call MouseControl.removeControl(). To enable it again, call MouseControl.regControl().

PointerLockControl

PointerLockControl object uses the pointerLock API and is a ideal choice for first person control. Usually, you should not have a MouseControl object and a PointerLockControl object enabled at the same time.

PointerLockControl controls a camera directly, to create a PointerLockControl object, use RayCaster.initPointerLockControl(camera, targetElement).

let canvas = createCanvas(width, height);
let world = RayCaster.createWorld(24,24);
let camera = RayCaster.createCamera({x:0, y:0}, {x:0.5, y:0.5}, 0.66, world, canvas);
let pointerLockControl = RayCaster.initPointerLockControl(camera, canvas.canvas);

Once you have a PointerLockControl set up, call PointerLockControl.lock() to lock the pointer. Note that your should trigger this with a user gesture like clicking on something.

To unlock the pointer, call PointerLockControl.unlock().

When the pointer is locked, moving the mouse will rotate or till the camera. You can adjust the speed of the camera movement by using PointerLockControl.setPointerSpeed(), the default speed is 0.002

For customization, you can set PointerLockControl.invertedXand PointerLockControl.invertedY to true to invert the axis and set PointerLockControl.switchXY to true to switch two axises.

To disable the controller, call PointerLockControl.removeControl(). To enable it again, call PointerLockControl.regControl().

KeyboardControl

KeyboardControl object provide a interface to deal with keyboard input. To create a KeyboardControl, call RayCaster.initKeyboardControl(). You can pass in a keyMap object into the function to load a customized control setting. The default keyMap looks like this:

defaultKeyMap = {
    forward: {
        key: ["w"],
    },
    backward: {
        key: ["s"],
    },
    goLeft: {
        key: ["a"],
    },
    goRight: {
        keys: ["d"],
    },
    turnLeft: {
        keys: ["q"],
    },
    turnRight: {
        keys: ["e"],
    },
    tiltUp: {
        keys: ["z"],
    },
    tiltDown: {
        keys: ["x"],
    },
    moveDoor: {
        keys: [" "],
    }
}

For each of the entry inside the keyMap, KeyboardControl will have a automatically updated property of the same name. Each of the keyMap item can have 3 properties. It must has a keys property indicating the keys it watching for. It can have a toggle property with a value of either up or down, if so, the value will be toggled at keyup or keydown. It can have a initValue property, which will be use to set the default value of the property when there's no keyboard input.

For example, with the default keyMap, when space key is pressed, KeyboardControl.moveDoor will be set to true. Below is an example of customize entry with additional properties:

keyMap = {
...
hideItem:{
    keys: ["Tab"],
    toggle: "up",
    initValue: true
}
...
}

// when tab key is released, `KeyboardControl.hideItem` will be toggled, at the very beginning it is set to `true``

You can use KeyboardControl.loadKeyMap(keyMap) to load your customized keyMap after initializing a KeyboardControl object. Alternatively you can use KeyboardControl.addItem() and KeyboardControl.removeItem() to modified the keyMap.

let keyboardControl = RayCaster.initKeyboardControl();
keyboardControl.addItem("jump", ["k"]);
// now the controller has a new property `keyboardControl.jump` which will be `true` when key k is pressed
keyboardControl.addItem("showMenu", ["Tab"], "down", true);
// now the controller has a new property `keyboardControl.showMenu` which initially is `true` and will be toggled when tab key is pressed down
keyboardControl.removeItem("jump");
// now the jump control is removed

To disable the controller, call KeyboardControl.removeControl(). To enable it again, call KeyboardControl.regControl().

0.0.1

3 months ago