Table of Contents
After working on a few small projects in VR, I wanted to create an actual game using my acquired knowledge. So UnconsciousMotifs, BatFerro and I were full of energy to start working on this project.
The idea
The game was developed for the 1-BIT JAM #4 and the main theme was Tower, so the first thing that came to mind was a tower defense. To fully embrace the theme, we decided to have the tower's height upgradable, this led us to the idea of the Tower of Babel, and from there, biblically accurate angels became an obvious choice.
Aesthetics
To quickly create models while also achieving a unique art style, I worked on a dithering shader, heavily inspired by Return of the Obra Dinn, though not quite at the same level. I implemented two GLSL shaders using the new CompositorEffect feature of Godot 4.3.
The first shader was needed for the dithering and edge detection.
1 #[compute]
2 #version 450
3
4 layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in;
5
6 layout(rgba16f, binding = 0, set = 0) uniform image2D screen_texture;
7 layout(binding = 0, set = 1) uniform sampler2D depth_texture;
8
9 layout(push_constant, std430) uniform Params {
10 vec2 screen_size;
11 float inv_proj_2w;
12 float inv_proj_3w;
13 } p;
14
15 const float pattern2[4] = float[](
16 0.0 / 4.0, 2.0 / 4.0,
17 3.0 / 4.0, 1.0 / 4.0
18 );
19
20 vec4 effect(vec4 color, ivec2 pixel_position, vec2 uv);
21
22 float get_bayer2_value(ivec2 pixel_coord);
23 float get_border(float thickness, vec2 uv);
24
25 float get_depth(vec2 uv);
26
27 void main() {
28 ivec2 pixel = ivec2(gl_GlobalInvocationID.xy);
29 vec2 size = p.screen_size;
30 int down_scaling_factor = 2;
31
32 vec2 uv = pixel * down_scaling_factor / size;
33
34 if (pixel.x >= size.x || pixel.y >= size.y) return;
35
36 vec4 color = imageLoad(screen_texture, pixel * down_scaling_factor);
37
38 color = effect(color, pixel, uv);
39
40 for (int i = 0; i < down_scaling_factor; i++) {
41 for (int j = 0; j < down_scaling_factor; j++) {
42 imageStore(screen_texture, pixel * down_scaling_factor + ivec2(i, j), color);
43 }
44 }
45 }
46
47 vec4 effect(vec4 color, ivec2 pixel_position, vec2 uv) {
48 float gray = color.r * 0.2125 + color.g * 0.7154 + color.b * 0.0721;
49 gray = clamp(get_border(0.002, uv) + gray, 0.0, 1.0);
50
51 float dither_value = get_bayer2_value(pixel_position);
52
53 if (gray > dither_value + 0.3) {
54 color = vec4(vec3(1.0), 1.0);
55 } else {
56 color = vec4(vec3(0.0), 1.0);
57 }
58
59 return color;
60 }
61
62 float get_bayer2_value(ivec2 pixel_coord) {
63 int index = (pixel_coord.y % 2) * 2 + (pixel_coord.x % 2);
64 return pattern2[index];
65 }
66
67 float get_border(float thickness, vec2 uv) {
68 return get_depth(uv + vec2(thickness, 0.0)) +
69 get_depth(uv - vec2(thickness, 0.0)) +
70 get_depth(uv + vec2(0.0, thickness)) +
71 get_depth(uv - vec2(0.0, thickness)) -
72 4.0 * get_depth(uv + vec2(0.0, 0.0));
73 }
74
75 float get_depth(vec2 uv) {
76 float depth = texture(depth_texture, uv).r;
77 float linear_depth = 1.0 / (depth * p.inv_proj_2w + p.inv_proj_3w);
78 return clamp(linear_depth / 100.0, 0.0, 1.0);
79 }
The second one was for selecting color palettes on the fly, at first I had in mind of chaning the palette every once in a while to spice up the mood and aesthetics.
1 #[compute]
2 #version 450
3
4 layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in;
5
6 layout(rgba16f, set = 0, binding = 0) uniform image2D color_image;
7
8 layout(push_constant, std430) uniform Params {
9 vec4 black_color;
10 vec4 white_color;
11 vec2 raster_size;
12 } params;
13
14 void main() {
15 ivec2 uv = ivec2(gl_GlobalInvocationID.xy);
16 ivec2 size = ivec2(params.raster_size);
17
18 if (uv.x >= size.x || uv.y >= size.y) {
19 return;
20 }
21
22 vec4 color = imageLoad(color_image, uv);
23
24 if (color.r > 0.5) {
25 color = params.white_color;
26 } else {
27 color = params.black_color;
28 }
29
30 imageStore(color_image, uv, color);
31 }
Gameplay
Initially, we had the cannon and the crossbow in mind as weapons, and the player could unlock or buy them in between of the phases.
The cannon deals a huge amount of damage, but charging it is time-consuming. The crossbow is easy to use for long-range attacks but deals low damage. While working on that, I came up with the idea of the harp, a fun and unique weapon. When all strings are touched in the correct order a note is shot. It's a short-range weapon that deals decent damage.
After some time spent fixing bugs and other mechanics, we introduced close-range weapons, such as the brick, which can be thrown and deals low damage, and the hammer, which is also throwable and can be used for long-range attacks. However, aiming is a bit difficult in a VR game, so to reward the player when thay hit an enemy with it we created an upgraded version of the hammer. When an enemy is hit, all enemies within a certain distance from it get stunned for a short time.
The Lurking Merchant
We wanted a mysterious-looking figure to sell you all the weapons and upgrades, to reinforce the theme of a corrupted ascent. He comes in between fights and will offer the player different items that can be purchased using orbs of light dropped by the enemies.
When an item is chosen, light will be removed from the scale and the merchant will let you know he's ready to seal the deal through a handshake.
If you have a VR headset and at least a GeForce GTX 1660 Ti you can run it!
Try it!