/* Raycasting renderer 1.0, by Stijn http://www.jazz2online.com/snippets/166/raycasting-renderer/ */ //raycast rendering for jazz jackrabbit 2 + (https://jj2.plus) //by stijn, 2023 (https://www.jazz2online.com) #pragma name "Raycasting renderer" // field of view float fov = TWO_PI / 6; // 60 degrees // initial direction of the player float angle = 0; // speed at which the player can rotate float turnSpeed = 0.01; // speed at which the player can move float moveSpeed = 2.0; // ray precision (1.0 = pixel perfect, higher=worse) float stepPrecision = 1.0; // max distance for a ray (bigger is slower in large levels) float maxDistance = 1250; float maxWallHeight = 100.0; // minimap scale, e.g. 16 = 1/16 scale int minimapScale = 16; // these are just constants and assorted things we'll use later, don't change int wMap = 0; int hMap = 0; float planeDistance; bool playerInit = false; bool levelInit = false; float turnX = 0.0; float turnY = 0.0; float xMap = 0.0; float yMap = 0.0; const int KEY_W = 87; const int KEY_A = 65; const int KEY_S = 83; const int KEY_D = 68; const int KEY_UP = 38; const int KEY_DOWN = 40; const int KEY_LEFT = 37; const int KEY_RIGHT = 39; const int KEY_M = 77; const float PI = 3.14159; const float HALF_PI = PI / 2; const float TWO_PI = 6.28318; bool showMinimap = true; jjPIXELMAP@ minimap; int keyDecay = 0; float max(float one, float two) { return (one > two) ? one : two; } float min(float one, float two) { return (one < two) ? one : two; } // we're not controlling the rabbit this time, so keep it fixed in place void onPlayer(jjPLAYER@ player) { if(!playerInit) { xMap = player.xPos; yMap = player.yPos; playerInit = true; } player.xSpeed = 0; player.ySpeed = 0; player.xPos = 0; player.yPos = 0; player.frozen = 1; } // in onMain, we handle set-up and movement controls void onMain() { if(keyDecay > 0) { keyDecay -= 1; } if(!levelInit) { // determine map size in pixels hMap = jjLayers[4].height * 32; wMap = jjLayers[4].width * 32; // how far is the player from the 'projection screen'? planeDistance = float(jjResolutionWidth) / 2.0 / tan(fov / 2.0); // initialise minimap as top-down view of the map, scaled @minimap = jjPIXELMAP(wMap / minimapScale, hMap / minimapScale); int mmX = 0; int mmY = 0; for(int y = 0; y < hMap; y += minimapScale) { for(int x = 0; x < wMap; x += minimapScale) { if(jjMaskedPixel(x, y)) { minimap[mmX, mmY] = 16; } else { minimap[mmX, mmY] = 22; } mmX += 1; } mmY += 1; mmX = 0; } minimap.save(jjAnimFrames[jjAnimations[jjAnimSets[ANIM::CUSTOM[0]].firstAnim].firstFrame]); jjConsole("Welcome to the third dimension"); jjConsole("Control the camera with the WASD keys"); jjConsole("Press M to toggle the mini-map (will give you some extra FPS)"); levelInit = true; } // WASD controls; W and S move, A and D turn (no strafing) // mouselook and/or independent movement and rotation could be implemented // here if(!(jjKey[KEY_A] && jjKey[KEY_D])) { if(jjKey[KEY_A]) { if(angle > 0) { angle -= turnSpeed; } else { angle = TWO_PI; } } if(jjKey[KEY_D]) { if(angle < TWO_PI) { angle += turnSpeed; } else { angle = 0; } } } // move forward and backwards if(jjKey[KEY_W] || jjKey[KEY_S]) { float xNew = xMap; float yNew = yMap; float stepX; float stepY; if(angle > (TWO_PI * 0.875) || angle <= (TWO_PI * 0.125)) { // 'up' stepY = -moveSpeed; stepX = abs(stepY) * tan(angle); } else if(angle > (TWO_PI * 0.125) && angle <= (TWO_PI * 0.375)) { // 'right' stepX = moveSpeed; stepY = abs(stepX) * tan(angle - HALF_PI); } else if(angle > (TWO_PI * 0.375) && angle <= (TWO_PI * 0.625)) { // 'down' stepY = moveSpeed; stepX = abs(stepY) * tan(-angle - PI); } else if(angle > (TWO_PI * 0.625) && angle <= (TWO_PI * 0.875)) { // 'left' stepX = -moveSpeed; stepY = abs(stepX) * tan(-angle - HALF_PI - PI); } if(jjKey[KEY_W]) { // forward xNew += stepX; yNew += stepY; } if(jjKey[KEY_S]) { // backward xNew -= stepX; yNew -= stepY; } // only allow movement if it doesn't put the player inside a wall if(!jjMaskedPixel(int(xNew), int(yNew))) { xMap = xNew; yMap = yNew; } } // toggle minimap if(jjKey[KEY_M] && keyDecay == 0) { showMinimap = !showMinimap; keyDecay = 25; } } // in onDrawAmmo, we do the rendering // this is pretty arbitrary, we just need a jjCANVAS handle bool onDrawAmmo(jjPLAYER@ player, jjCANVAS@ screen) { if(!levelInit) { // wait for set-up in onMain to complete return false; } // ceiling screen.drawRectangle(0, 0, jjResolutionWidth, jjResolutionHeight / 2, 32); // floor screen.drawRectangle(0, jjResolutionHeight / 2, jjResolutionWidth, jjResolutionHeight / 2, 64); // get 'empty' minimap to draw current vision cone on jjPIXELMAP@ rayMap = jjPIXELMAP(jjAnimFrames[jjAnimations[jjAnimSets[ANIM::CUSTOM[0]].firstAnim].firstFrame]); // cast some rays // the ray will be at a different angle for each 'slice' of the wall - we // use the configured field of view to calculate by how much that angle // increases for each subsequent slice (i.e. vertical line of pixels) float fovStep = fov / float(jjResolutionWidth); float localAngle = angle - (fov / 2) - fovStep; // start on the left // for each pixel of horizontal resolution, determine if a wall slice // should be drawn and if yes of what height for(int x = 0; x < jjResolutionWidth; x += 1) { localAngle += fovStep; // normalize angle (does AS have fmod?) if(localAngle < 0) { localAngle += TWO_PI; } if(localAngle > TWO_PI) { localAngle -= TWO_PI; } float stepX, stepY; float calcAngle = localAngle + (HALF_PI / 2); // we cast the ray by determining a horizontal and vertical delta/step, // and then checking pixels along a path by starting at the player // position and adjusting the position by the calculated stepX and // stepY. if performance becomes an issue, increasing the step sizes // is one way to speed things up - as it is we never increment by more // than a single pixel for pixel-perfect mapping, but that is probably // overkill if(localAngle > (TWO_PI * 0.875) || localAngle <= (TWO_PI * 0.125)) { // facing 'up' stepY = -stepPrecision; stepX = abs(stepY) * tan(localAngle); } else if(localAngle > (TWO_PI * 0.125) && localAngle <= (TWO_PI * 0.375)) { // facing 'right' stepX = stepPrecision; stepY = abs(stepX) * tan(localAngle - HALF_PI); } else if(localAngle > (TWO_PI * 0.375) && localAngle <= (TWO_PI * 0.625)) { // facing 'down' stepY = stepPrecision; stepX = abs(stepY) * tan(-localAngle - PI); } else if(localAngle > (TWO_PI * 0.625) && localAngle <= (TWO_PI * 0.875)) { // facing 'left' stepX = -stepPrecision; stepY = abs(stepX) * tan(-localAngle - HALF_PI - PI); } // traverse the ray until we hit the edges of the map or a masked pixel // traditional raycasters often don't check every pixel but instead use // an underlying lower-resolution map of 'blocks' to check against - this // would work well with JJ2's tile system but we have the computing power // to just check every pixel and it allows using e.g. sloped tiles for // different wall shapes float xRay = xMap; float yRay = yMap; float distance = 0.0; // what we want to find out by casting the ray is the distance to the wall // the distance for each step can thus be determined through pythagoras' // theorem, since the x and y step sizes form two sides of a triangle and // the traversed distance is the third side float stepLength = sqrt(pow(abs(stepX), 2) + pow(abs(stepY), 2)); // default colour int colour = 24; while(distance < maxDistance) { if(xRay < 0 || xRay > wMap || yRay < 0 || yRay > hMap) { // out of bounds break; } if(jjMaskedPixel(int(xRay), int(yRay))) { // masked pixel, draw wall // assume colors are from the first half of the palette - use // the first color of the relevant gradient so we can use the // others for hue later // texture mapping could be implemented here jjPIXELMAP tile(jjLayers[4].tileGet(int(xRay / 32), int(yRay / 32))); colour = int(tile[xRay % 32, yRay % 32] / 8) * 8; break; } else if(showMinimap) { // draw vision cone on minimap rayMap[int(xRay / minimapScale), int(yRay / minimapScale)] = 66; } distance += stepLength; xRay += stepX; yRay += stepY; } if(distance >= maxDistance) { // nothing within the scanning distance, assume no wall continue; } // correct fisheye effect and determine rendered wall height // https://www.permadi.com/tutorial/raycast/rayc8.html distance *= cos(localAngle - angle); distance = max(0.1, distance); // avoid divide by zero int wallHeight = int(maxWallHeight / distance * planeDistance); if(wallHeight > 0) { // draw the wall slice! actually just a rectangle of the calculated // height and colour - the 750 is arbitrary here and determines how // far away something needs to be to be the 'darkest' hue possible int hue = int(float(min(distance, 750.0) / 750) * 7.0); screen.drawRectangle(x, (jjResolutionHeight / 2) - (wallHeight / 2), 1, wallHeight, colour + hue); } } // minimap if(showMinimap) { // 3x3 rectangle to mark player position int mmX = int(xMap / minimapScale); int mmY = int(yMap / minimapScale); rayMap[mmX - 1, mmY - 1] = 64; rayMap[mmX - 1, mmY] = 64; rayMap[mmX - 1, mmY + 1] = 64; rayMap[mmX, mmY - 1] = 64; rayMap[mmX, mmY] = 64; rayMap[mmX, mmY + 1] = 64; rayMap[mmX + 1, mmY - 1] = 64; rayMap[mmX + 1, mmY] = 64; rayMap[mmX + 1, mmY + 1] = 64; // display as sprite rayMap.save(jjAnimFrames[jjAnimations[jjAnimSets[ANIM::CUSTOM[0]].firstAnim].firstFrame + 1]); screen.drawSprite(25, 25, ANIM::CUSTOM[0], 1, 0); } return false; }