aboutsummaryrefslogtreecommitdiff
path: root/src/player.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/player.zig')
-rw-r--r--src/player.zig480
1 files changed, 480 insertions, 0 deletions
diff --git a/src/player.zig b/src/player.zig
new file mode 100644
index 0000000..6082b74
--- /dev/null
+++ b/src/player.zig
@@ -0,0 +1,480 @@
+// This file is part of ZiRC
+//
+// Copyright (C) 2021, tslil clingman
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+const std = @import("std");
+
+const RenderWindow = @import("sfml").graphics.RenderWindow;
+const Sprite = @import("sfml").graphics.Sprite;
+const Texture = @import("sfml").graphics.Texture;
+const Image = @import("sfml").graphics.Image;
+const Colour = @import("sfml").graphics.Color;
+
+const level = @import("level.zig");
+const constants = @import("constants.zig");
+
+fn renderSlice(
+ window: RenderWindow,
+ sprite: Sprite,
+ col: i32, // which column
+ top: f32, // top of wall
+ height: f32, // how tall
+ draw_frac: f32, // how much to draw, as a fraction; > 1 means repeat texture
+ texfrac: f32, // how far along the texture
+ texture: u8, // which texture index
+) void {
+ // we need ceil here so that we draw always to or past the edge of the screen
+ const draw_height = @floatToInt(c_int, std.math.ceil(draw_frac * constants.TextureDim));
+ const total_height = height * constants.VFact;
+
+ const xpos = @intToFloat(f32, col) * constants.HFact;
+ const ypos = constants.ScreenHeight / 2 + (constants.PlaneHeight / 2 - top) * constants.VFact;
+
+ const tind = texture * @floatToInt(c_int, constants.TextureDim);
+ const left = tind + @floatToInt(c_int, texfrac * constants.TextureDim);
+
+ sprite.setPosition(.{ .x = xpos, .y = ypos });
+ sprite.setScale(.{ .x = constants.HFact, .y = total_height / constants.TextureDim });
+ sprite.setTextureRect(.{ .top = 0, .left = left, .width = 1, .height = draw_height });
+ window.draw(sprite, null);
+}
+
+fn playerDistComp(pos: [2]f32, lhs: level.Object, rhs: level.Object) bool {
+ const lx = lhs.pos_x - pos[0];
+ const ly = lhs.pos_y - pos[1];
+ const rx = rhs.pos_x - pos[0];
+ const ry = rhs.pos_y - pos[1];
+
+ return (lx * lx + ly * ly > rx * rx + ry * ry);
+}
+
+pub fn Player(PlaneWidth: f32, PlaneHeight: f32) type {
+ const FOV: f32 = std.math.pi / 3.0;
+ const PlanePixels = PlaneWidth * PlaneHeight;
+ // given the desired width of the image, how far away must
+ // the projection plane be from the camera?
+ const FOV_SCALE = 2 * std.math.tan(FOV / 2);
+ const PlaneDist = PlaneWidth / FOV_SCALE;
+
+ return struct {
+ pos_x: f32,
+ pos_y: f32,
+ ang: f32,
+ vel_x: f32 = 0,
+ vel_y: f32 = 0,
+ acc_x: f32 = 0,
+ acc_y: f32 = 0,
+ height: f32 = 2.0 * (1.8 / 2.5), // TODO
+ z_buffer: std.BoundedArray(f32, PlanePixels),
+
+ anim_step: f32 = 0,
+
+ // standing still at the given location, looking in direction ang
+ pub fn new(pos_x: f32, pos_y: f32, ang: f32) !@This() {
+ const infs = [_]f32{std.math.inf(f32)} ** PlanePixels;
+ return Player(PlaneWidth, PlaneHeight){
+ .pos_x = pos_x,
+ .pos_y = pos_y,
+ .ang = ang,
+ // TODO: is there some clever way to avoid this long name?
+ .z_buffer = try std.BoundedArray(f32, PlanePixels).fromSlice(&infs),
+ };
+ }
+
+ pub fn tick(self: *@This(), map: level.Map) void {
+ const dt = 1 / 30.0;
+ const v_min = 0.8;
+ const v_decay = 1 / 1.25;
+
+ var next_x = self.pos_x + self.vel_x * dt;
+ var next_y = self.pos_y + self.vel_y * dt;
+
+ // Collision detection
+ const min_dist: f32 = 0.1;
+ const fx = std.math.floor(self.pos_x);
+ const fy = std.math.floor(self.pos_y);
+ const ix = @floatToInt(i32, fx);
+ const iy = @floatToInt(i32, fy);
+ const nix = @floatToInt(i32, std.math.floor(next_x + if (self.vel_x > 0) min_dist else -min_dist));
+ const niy = @floatToInt(i32, std.math.floor(next_y + if (self.vel_y > 0) min_dist else -min_dist));
+
+ if (!map.inBounds(nix, iy) or map.lookup(nix, iy).height > 0) {
+ next_x = fx + if (self.vel_x > 0) 1 - min_dist else min_dist;
+ self.vel_x = 0;
+ self.acc_x = 0;
+ }
+
+ if (!map.inBounds(ix, niy) or map.lookup(ix, niy).height > 0) {
+ next_y = fy + if (self.vel_y > 0) 1 - min_dist else min_dist;
+ self.vel_y = 0;
+ self.acc_y = 0;
+ }
+
+ // Update position
+ self.pos_x = next_x;
+ self.pos_y = next_y;
+
+ // Update velocity
+ self.vel_x += self.acc_x * dt;
+ self.vel_y += self.acc_y * dt;
+
+ const vd = v_decay * std.math.sqrt(self.vel_x * self.vel_x + self.vel_y * self.vel_y);
+ if (vd < v_min) {
+ self.vel_x = 0;
+ self.vel_y = 0;
+ } else {
+ // If we're moving update our animation state
+ self.anim_step += 1;
+ self.height -= std.math.sin(self.anim_step / 10 * std.math.pi) * 0.015;
+
+ self.vel_x *= v_decay;
+ self.vel_y *= v_decay;
+ }
+ }
+
+ pub fn renderWorld(
+ self: *@This(),
+ window: RenderWindow,
+ walls_sprite: Sprite,
+ objects_sprite: Sprite,
+ surfaces_image: Image,
+ rendered_surfaces_texture: Texture,
+ rendered_surfaces_sprite: Sprite,
+ map: level.Map,
+ ) !void {
+ // Fist reset the z_buffer
+ var i: usize = 0;
+ while (i < self.z_buffer.len) : (i += 1) {
+ self.z_buffer.set(i, std.math.inf(f32));
+ }
+
+ var pixels = [_]Colour{Colour.fromRGBA(0, 0, 0, 0)} ** (PlaneWidth * PlaneHeight);
+
+ // then draw all the walls and populate the z_buffer, while also
+ // rendering the surfaces below the horizon to the pixel array
+ self.renderCells(window, walls_sprite, surfaces_image, map, &pixels);
+
+ // then render the ceilings to our pixel array
+ self.renderCeilingsToTexture(surfaces_image, map, &pixels);
+
+ // we're now ready to draw the surfaces
+ try rendered_surfaces_texture.updateFromPixels(&pixels, null);
+ window.draw(rendered_surfaces_sprite, null);
+
+ // use the z_buffer to render sprites
+ self.renderObjects(window, objects_sprite, map);
+ }
+
+ fn renderObjects(
+ self: @This(),
+ window: RenderWindow,
+ objects_sprite: Sprite,
+ map: level.Map,
+ ) void {
+ std.sort.sort(level.Object, map.objects.items,
+ // Wow, context with an arbitrary type! No macros, just
+ // Zig all the way down!
+ [2]f32{ self.pos_x, self.pos_y }, playerDistComp);
+
+ const self_cos = std.math.cos(self.ang);
+ const self_sin = std.math.sin(self.ang);
+
+ for (map.objects.items) |obj| {
+ const ox = obj.pos_x - self.pos_x;
+ const oy = obj.pos_y - self.pos_y;
+
+ // We compute the two coordinates of rotating by -self.ang, the
+ // first of which gives the perpendicular distance to the plane
+ // of projection, and the second of which gives the
+ // (unprojected) centre of the object.
+ const perp_distance = self_cos * ox + self_sin * oy;
+ const centre = self_sin * ox - self_cos * oy;
+
+ // NOTE: in the below we have applied the magic scaling factor
+ // of FOV_SCALE. I don't understand how this compensates for the
+ // linear interpolation incorrectness we do elsewhere, but
+ // somehow it scales the *correct* values we compute above into
+ // whatever agrees with the wall and floor rendering voodoo.
+
+ // This quantity is independent of FOV_SCALE because it enters
+ // both via centre and perp_distance
+ const proj_centre = PlaneWidth / 2 + PlaneDist * centre / perp_distance;
+
+ // Here's the magic adjustment
+ const scaled_perp_distance = FOV_SCALE * perp_distance;
+ const width = PlaneDist * obj.width / scaled_perp_distance;
+ const left = proj_centre - width / 2;
+
+ // TODO: prune before this?
+ if (left + width < 0 or left >= PlaneWidth) continue;
+
+ const height = PlaneDist * obj.height / scaled_perp_distance;
+ const top = PlaneHeight / 2 + PlaneDist * (obj.height - self.height + obj.pos_z) / scaled_perp_distance;
+
+ // TODO: likewise?
+ if (top < 0 or top - height >= PlaneHeight) continue;
+
+ // Something is on the screen, let's draw it!
+ const start = std.math.max(0, left);
+ const end = @floatToInt(i32, std.math.min(left + width, PlaneWidth - 1));
+
+ var tex_frac: f32 = std.math.clamp((start - left) / width, 0, 1);
+ var col: i32 = @floatToInt(i32, start);
+ const tex_frac_step = 1 / width;
+ while (col < end) : ({
+ col += 1;
+ tex_frac += tex_frac_step;
+ }) {
+ var bottom = std.math.min(top, PlaneHeight);
+ while (bottom >= 0 and bottom >= top - height) : (bottom -= 1) {
+ const index = @intCast(usize, col * @floatToInt(i32, PlaneHeight) + @floatToInt(i32, bottom));
+ if (self.z_buffer.get(index) < scaled_perp_distance) {
+ bottom += 1;
+ break;
+ }
+ }
+ const draw_frac = std.math.clamp((top - bottom) / height, 0, 1);
+ renderSlice(window, objects_sprite, col, top, height, draw_frac, tex_frac, obj.texture);
+ }
+ }
+ }
+
+ fn renderCells(
+ self: *@This(),
+ window: RenderWindow,
+ walls_sprite: Sprite,
+ floors_image: Image,
+ map: level.Map,
+ pixels: []Colour,
+ ) void {
+ // This is a TERRIBLE hack: for whatever reason *linearly*
+ // interpolating on the direction vectors gives
+ // perspective-correct-seeming walls!
+ const cos_first = std.math.cos(self.ang + 0.5 * FOV);
+ const cos_last = std.math.cos(self.ang - 0.5 * FOV);
+ const sin_first = std.math.sin(self.ang + 0.5 * FOV);
+ const sin_last = std.math.sin(self.ang - 0.5 * FOV);
+
+ const cos_step = (cos_last - cos_first) / PlaneWidth;
+ const sin_step = (sin_last - sin_first) / PlaneWidth;
+
+ var col: u16 = 0;
+ var cosra = cos_first;
+ var sinra = sin_first;
+ while (col < PlaneWidth) : ({
+ col += 1;
+ cosra += cos_step;
+ sinra += sin_step;
+ }) {
+ // Observe that sqrt(1+tan^2) = abs(1/cos) sqrt(cos^2+sin^2) =
+ // abs(1/cos). Similarly so for cot, hence we obtain the
+ // following lengths for the hypotenuses assuming that x
+ // (respectively y) are unit length and the angle is ra. This
+ // for whatever reasons still works when we linearly interpolate
+ // on cos and sin!
+ const dy_for_x_step = std.math.fabs(1 / cosra);
+ const dx_for_y_step = std.math.fabs(1 / sinra);
+
+ var step_x: i32 = -1;
+ var step_y: i32 = -1;
+
+ var dist_x: f32 = undefined;
+ var dist_y: f32 = undefined;
+
+ var ipos_x: i32 = @floatToInt(i32, std.math.floor(self.pos_x));
+ var ipos_y: i32 = @floatToInt(i32, std.math.floor(self.pos_y));
+
+ // looking right
+ if (cosra >= 0) {
+ step_x = 1;
+ // assuming unit size grid cells
+ dist_y = (@intToFloat(f32, ipos_x) + 1 - self.pos_x) * dy_for_x_step;
+ } else {
+ dist_y = (self.pos_x - @intToFloat(f32, ipos_x)) * dy_for_x_step;
+ }
+
+ if (sinra >= 0) {
+ step_y = 1;
+ dist_x = (@intToFloat(f32, ipos_y) + 1 - self.pos_y) * dx_for_y_step;
+ } else {
+ dist_x = (self.pos_y - @intToFloat(f32, ipos_y)) * dx_for_y_step;
+ }
+
+ var top: f32 = undefined;
+ var distance: f32 = 0;
+ var still_drawing = true;
+ var highest_point: f32 = 0;
+ var horizontal_hit: bool = undefined;
+ while (still_drawing and map.inBounds(ipos_x, ipos_y)) : ({
+ // Find the next cell on our path
+ if (dist_y < dist_x) {
+ horizontal_hit = false;
+ distance = dist_y;
+ dist_y += dy_for_x_step;
+ ipos_x += step_x;
+ } else {
+ horizontal_hit = true;
+ distance = dist_x;
+ dist_x += dx_for_y_step;
+ ipos_y += step_y;
+ }
+ }) {
+ const cell = map.lookup(ipos_x, ipos_y);
+
+ // Is there a wall?
+ if (cell.height > 0) {
+ // project the top of the wall
+ top = PlaneHeight / 2 + PlaneDist * (cell.height - self.height) / distance;
+
+ // Does the wall extend above what we've draw?
+ if (top > highest_point) {
+
+ // If we reach the top we have to stop!
+ if (top > PlaneHeight) {
+ still_drawing = false;
+ }
+
+ // compute the height of this wall
+ const total_length = PlaneDist * cell.height / distance;
+
+ // as well as the fraction we'll be drawing
+ const draw_length = top - highest_point;
+ const draw_frac = cell.height * std.math.clamp(draw_length / total_length, 0, 1);
+
+ // we need the distance to calculate the fractional
+ // part of the relevant coordinate for texture
+ // mapping of the walls
+ const hit_coordinate = if (horizontal_hit) distance * cosra + self.pos_x else distance * sinra + self.pos_y;
+ var texfrac = std.math.modf(hit_coordinate).fpart;
+
+ // we also want to be sure that we're consistently orienting
+ // textures, in this case clockwise
+ if ((horizontal_hit and sinra < 0) or (!horizontal_hit and cosra > 0)) texfrac = 1 - texfrac;
+
+ // draw the wall
+ renderSlice(window, walls_sprite, col, top, total_length / cell.height, draw_frac, texfrac, cell.wall_texture);
+
+ // record that there's a wall here in the z_buffer
+ var y = @floatToInt(i32, std.math.min(top, PlaneHeight - 1));
+ while (y > @floatToInt(i32, highest_point)) : (y -= 1) {
+ const index = @intCast(usize, col * @floatToInt(i32, PlaneHeight) + y);
+ self.z_buffer.set(index, distance);
+ }
+ highest_point = top;
+ }
+ }
+
+ // do we potentially draw the top of this cell?
+ if (highest_point < PlaneHeight / 2) {
+ if (dist_y < dist_x) {
+ distance = dist_y;
+ } else {
+ distance = dist_x;
+ }
+
+ // Note: next_top can never exceed PlaneHeight / 2
+ // in the body of the next block. If the wall is
+ // taller than us the back edge is lower than the
+ // front one so this check will fail as we just drew
+ // it (or higher than it). If the wall is shorter
+ // then the back edge is at most the horizon.
+ const next_top = PlaneHeight / 2 + PlaneDist * (cell.height - self.height) / distance;
+
+ // only if we can see some part of it
+ if (next_top > highest_point) {
+ top = highest_point;
+ while (top <= next_top and top < PlaneHeight / 2) : (top += 1) {
+ const row_dist = (self.height - cell.height) * PlaneDist / (PlaneHeight / 2 - top);
+ const ptop = @floatToInt(usize, top + 1);
+ const itop = @floatToInt(usize, PlaneHeight) - ptop;
+
+ // draw the correct pixel
+ const sx = std.math.modf(self.pos_x + row_dist * cosra);
+ const sy = std.math.modf(self.pos_y + row_dist * sinra);
+ const toff = cell.floor_texture * @floatToInt(c_uint, constants.TextureDim);
+ const px = @floatToInt(c_uint, constants.TextureDim * std.math.fabs(sx.fpart));
+ const py = @floatToInt(c_uint, constants.TextureDim * std.math.fabs(sy.fpart));
+ const val = floors_image.getPixel(.{ .x = toff + px, .y = py });
+ pixels[itop * @floatToInt(usize, PlaneWidth) + col] = val;
+
+ // record in the z_buffer only if we're above the floor!
+ if (cell.height > 0) {
+ const index = col * @floatToInt(usize, PlaneHeight) + ptop;
+ self.z_buffer.set(index, row_dist);
+ }
+ }
+ highest_point = next_top;
+ }
+ }
+ }
+ }
+ }
+
+ fn renderCeilingsToTexture(
+ self: @This(),
+ surfaces_image: Image,
+ map: level.Map,
+ pixels: []Colour,
+ ) void {
+ // Again, another TERRIBLE hack: we do the same nasty linear
+ // interpolation trick and for whatever reason the floors look fine.
+ const cos_first = std.math.cos(self.ang + 0.5 * FOV);
+ const sin_first = std.math.sin(self.ang + 0.5 * FOV);
+ const cos_last = std.math.cos(self.ang - 0.5 * FOV);
+ const sin_last = std.math.sin(self.ang - 0.5 * FOV);
+
+ var row: usize = 0;
+ while (row < PlaneHeight / 2) : (row += 1) {
+ const frow = (PlaneHeight / 2 - @intToFloat(f32, row));
+ const row_dist = (constants.MAX_HEIGHT - self.height) * PlaneDist / frow;
+
+ const dx_step = row_dist * (cos_last - cos_first) / PlaneWidth;
+ const dy_step = row_dist * (sin_last - sin_first) / PlaneWidth;
+
+ var col: usize = 0;
+ var dx = row_dist * cos_first;
+ var dy = row_dist * sin_first;
+
+ while (col < PlaneWidth) : ({
+ col += 1;
+ dx += dx_step;
+ dy += dy_step;
+ }) {
+ const x = self.pos_x + dx;
+ const y = self.pos_y + dy;
+
+ const sx = std.math.modf(x);
+ const sy = std.math.modf(y);
+
+ const ix = @floatToInt(i32, sx.ipart);
+ const iy = @floatToInt(i32, sy.ipart);
+
+ const index = col * @floatToInt(usize, PlaneHeight) + @floatToInt(usize, PlaneHeight - 1) - row;
+ if (map.inBounds(ix, iy) and row_dist < self.z_buffer.get(index)) {
+ const cell = map.lookup(ix, iy);
+ const toff = cell.ceiling_texture * @floatToInt(c_uint, constants.TextureDim);
+ const px = @floatToInt(c_uint, constants.TextureDim * std.math.fabs(sx.fpart));
+ const py = @floatToInt(c_uint, constants.TextureDim * std.math.fabs(sy.fpart));
+
+ const val = surfaces_image.getPixel(.{ .x = toff + px, .y = py });
+
+ pixels[row * @floatToInt(usize, PlaneWidth) + col] = val;
+ }
+ }
+ }
+ }
+ };
+}