Compare commits

...

No commits in common. "archive-3d-survival" and "master" have entirely different histories.

171 changed files with 6654 additions and 5660 deletions

21
.gitignore vendored
View File

@ -1,3 +1,20 @@
/bin # Build output.
.clangd /bin/
build/
# Editor/IDE, whatever people use.
.vscode/
.idea/
*.suo
*.user
*.clangd/
compile_commands.json
# For those messy OS'es that like to put files everywhere.
.DS_Store
# Misc.
assets/design_doc.org assets/design_doc.org
.clangd
*.swp
*.db

View File

@ -1,30 +1,55 @@
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.16)
project(bettola CXX C)
project(bettola VERSION 0.1) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# Let's use C++17?
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
# include directories.. # === Sol2 ===
include_directories(libbettola/include) include(FetchContent)
include_directories(src) FetchContent_Declare(
sol2
GIT_REPOSITORY https://github.com/ThePhD/sol2.git
GIT_TAG v3.3.1
)
FetchContent_MakeAvailable(sol2)
# Deps. # === Asio ===
find_package(SDL3 REQUIRED) FetchContent_Declare(
find_package(GLEW REQUIRED) asio
find_package(OpenGL REQUIRED) GIT_REPOSITORY https://github.com/chriskohlhoff/asio
git_TAG asio-1-36-0
)
FetchContent_MakeAvailable(asio)
# Build our shared lib. # === SQLite ===
add_subdirectory(libbettola) # Supress the developer warning for using Populate with declared content.
# We need it because sqlite zip isn't a CMAKE project.
cmake_policy(SET CMP0169 OLD)
FetchContent_Declare(
sqlite_source
URL https://sqlite.org/2025/sqlite-amalgamation-3500400.zip
URL_HASH SHA256=1d3049dd0f830a025a53105fc79fd2ab9431aea99e137809d064d8ee8356b032
DOWNLOAD_EXTRACT_TIMESTAMP true
)
FetchContent_GetProperties(sqlite_source)
if(NOT sqlite_source_POPULATED)
FetchContent_Populate(sqlite_source)
add_library(sqlite STATIC "${sqlite_source_SOURCE_DIR}/sqlite3.c")
target_include_directories(sqlite PUBLIC "${sqlite_source_SOURCE_DIR}")
endif()
# Revert policy to default.
cmake_policy(SET CMP0169 NEW)
# Will need to clean build each time you add a new file though -.- # === sqlite_modern_cpp (SQLite wrapper) ===
file(GLOB_RECURSE SOURCES "src/*.cpp") FetchContent_Declare(
add_executable(bettola ${SOURCES}) sqlite_modern_cpp
GIT_REPOSITORY https://github.com/SqliteModernCpp/sqlite_modern_cpp.git
GIT_TAG v3.2
)
FetchContent_MakeAvailable(sqlite_modern_cpp)
target_link_libraries(bettola PRIVATE bettola_lib SDL3::SDL3 GLEW::glew OpenGL::GL) add_subdirectory(common)
add_subdirectory(client)
# Server executable. add_subdirectory(server)
file(GLOB_RECURSE SERVER_SOURCES "srv/*.cpp")
add_executable(bettola_server ${SERVER_SOURCES})
target_link_libraries(bettola_server PRIVATE bettola_lib)

View File

@ -1,46 +1,38 @@
SHELL:=/bin/bash # Wrapper Makefile.
BUILD_DIR:=bin
PROJECT_NAME:=bettola
EXECUTABLE:=$(BUILD_DIR)/$(PROJECT_NAME)
CXX_COMPILER:=clang++ # Build artifacts.
CMAKE_MAKEFILE:=$(BUILD_DIR)/Makefile BUILD_DIR := bin
.PHONY: all build config run clean help # Client path.
CLIENT_EXE := $(BUILD_DIR)/client/bettolac
# Server path.
SERVER_EXE := $(BUILD_DIR)/server/bettolas
# Default. .PHONY: all build config runc sp runs clean
# Default target when running 'make'.
all: build all: build
# Build project, if not configured, then do that first. build: config
build: $(CMAKE_MAKEFILE) @echo "=== Building Bettola. ==="
@echo "==== Building Bettola ====" @$(MAKE) -C $(BUILD_DIR)
@cmake --build $(BUILD_DIR)
# run 'config' target if the build directory or CMake cache is missing.
$(CMAKE_MAKEFILE):
$(MAKE) config
config: config:
@echo "==== Configuring Bettola with CMake ====" @echo "=== Configuring Project. ==="
@mkdir -p $(BUILD_DIR) @cmake -B $(BUILD_DIR) -S . -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
@cmake -S . -B$(BUILD_DIR) -DCMAKE_CXX_COMPILER=$(CXX_COMPILER) -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
# Build and run. runc: build
run: all @echo "=== Running Bettola Client. ==="
@echo "==== Running Bettola ====" @$(CLIENT_EXE)
$(EXECUTABLE)
sp: build
@echo "=== Running Bettola Client (Single Player). ==="
@$(CLIENT_EXE) -sp
runs: build
@echo "=== Running Bettola Server. ==="
@$(SERVER_EXE)
# Remove build dir.
clean: clean:
@echo "==== Cleaning Bettola ====" @echo "=== Cleaning Build Directory. ==="
@rm -rf $(BUILD_DIR) @rm -rf $(BUILD_DIR)
@echo "==== Project Cleaned ===="
help:
@echo "Available commands:"
@echo " make - Build the project (default)."
@echo " make build - Build the project."
@echo " make run - Build and run the project."
@echo " make config - Force CMake to re-configure the project."
@echo " make clean - Remove all build files"

View File

@ -1,35 +1,44 @@
#+TITLE: Bettola Game #+TITLE: Working Title: Bettola
A 2D RPG game and engine created with C++, SDL3, and OpenGL. A multiplayer hacking simulator with a graphical OS, built in C++ and OpenGL.
* Dependencies * Dependencies
The following dependencies are requird: The following dependencies are required:
- A C++ compiler (e.g., clang, g++) - A C++ compiler (e.g., clang, g++)
- CMake (3.16 or newer) - CMake (3.16 or newer)
- SDL3 development libraries - SDL3 development libraries
- Asio networking library (managed by CMake)
- GLEW development libraries - GLEW development libraries
- FreeType development libraries
- Lua 5.4 development libraries
- sol2 (header-only, managed by CMake)
** Installation (Debian) ** Installation (Debian)
You can install all required dependencies with the following command: You can install most required dependencies with the following command:
#+BEGIN_SRC bash #+BEGIN_SRC bash
sudo apt update sudo apt update
sudo apt install build-essential clang cmake libsdl3-dev libglew-dev sudo apt install build-essential clang cmake libsdl3-dev libglew-dev libfreetype-dev liblua5.4-dev
#+END_SRC #+END_SRC
* Build Instructions * Build Instructions
This project uses a top-level Makefile to simplify the CMake workflow. All build artifacts will be placed in the =bin/= directory. This project uses a top-level Makefile to simplify the CMake workflow. Build
artifacts will be placed in the =bin/= directory.
Run the following from the root of the project directory. Simply run the following commands from the root of the project directory:
- *Build the project:* - *Build the project:*
The executable will be located in =bin/bettola=.
#+BEGIN_SRC bash #+BEGIN_SRC bash
make make
#+END_SRC #+END_SRC
- *Build and run the project:* - *Build and run the client:*
#+BEGIN_SRC bash #+BEGIN_SRC bash
make run make runc
#+END_SRC
- *Build and run the server:*
#+BEGIN_SRC bash
make runs
#+END_SRC #+END_SRC
- *Clean the project:* - *Clean the project:*
@ -37,3 +46,53 @@ Run the following from the root of the project directory.
#+BEGIN_SRC bash #+BEGIN_SRC bash
make clean make clean
#+END_SRC #+END_SRC
* Project Structure
The codebase is organised into three main components:
- =common/=: A shared library (=libbettola=) containing code used by both the
client and server.
- =client/=: The game client (=bettolac=), handles rendering, UI, and user input.
- =server/=: The game server (=bettolas=) manages game state and the world
simulation.
* Planned Features
/Note: [X] indicates a feature that is currently in progress or complete.
** Core Gameplay & Hacking
- [X] Custom-built graphical OS desktop environment.
- [X] Interactive terminal with command history and scrolling.
- [X] Draggable and focusable UI windows.
- [X] Local and remote virtual file systems (VFS).
- [X] Core filesystem commands (=ls=, =cd=).
- [X] Remote system connections via ingame =ssh= tool.
- [X] Network scanning tools to discover hosts, open ports, and running
services.
- [ ] A deep exploit system based on service versions (e.g., SSH, FTP, HTTP).
- [ ] Ability to find, modify, and write new exploits.
- [ ] Functionality to upload/download files to and from remote systems.
- [ ] Log cleaning utilities and other tools for covering your tracks.
- [ ] Social engineering through in-game email and websites.
** The World
- [ ] Narrative-driven main storyline (serves as tutorial before the sandbox
world).
- [ ] Emergent gameplay arising from the interaction of world systems.
- [ ] A persistent, shared "core" universe of high-level NPC networks.
- [ ] A unique, procedurally generated "local neighbourhood" for each new
player.
- [ ] NPC factions and corporations with simulated goals and stock markets.
- [ ] Dynamic missions generated organically from the state of the world.
- [ ] Active NPC system administrators who patch vulnerabilities and hunt for
intruders.
** Player Systems & Progression
- [X] Embedded Lua scripting engine for creating custom tools.
- [X] In-game code editor with syntax highlighting etc.
- [ ] Secure, sandboxed execution of player scripts with CPU/RAM as a
resource.
- [ ] An in-game internet with a web browser, email, and online banking.
- [ ] Online stores for purchasing virtual hardware, software, and exploits.
- [ ] The ability to purchase and upgrade dedicated servers.
- [ ] Hosting of player-owned services (web, FTP, etc.).
- [ ] Creation of custom websites using HTML and basic JS.
- [ ] Player-to-player secure messaging and file transfers.

24
assets/boot_messages.txt Normal file
View File

@ -0,0 +1,24 @@
[ 0.000000] Bettola version 6.1.0-bettola (dev@bettola)
[ 0.000000] Command line: BOOT_IMAGE=/vmbettola-6.1.0 ro quiet
[ 0.134589] ACPI: PM-Timer IO Port: 0x808
[ 0.345821] pci 0000:00:02.0: vgaarb: setting as boot VGA device
[ 0.345911] pci 0000:00:03.0: enp0s3: identified as [B77A:1337]
[ 0.582190] systemd[1]: Starting systemd-journald.service...
[ 0.621337] systemd-journald[218]: Journal started.
[ 1.123456] EXT4-fs (sda1): mounted filesystem with ordered data mode.
[ 1.567890] systemd[1]: Reached target Local File Systems.
[ 1.890123] systemd[1]: Starting systemd-logind.service...
[ 2.101122] systemd[1]: Starting NetworkManager.service...
[ 2.334455] NetworkManager[310]: <info> [1678886400.123] NetworkManager (version 1.40.0) is
starting...
[ 2.800100] enp0s3: Link is up at 1000 Mbps, full duplex.
[ 3.123456] systemd[1]: Reached target Network.
[ 3.500000] systemd[1]: Starting Bettola Daemon...
[ 3.600000] bettolad[420]: Initializing VFS...
[ 3.700000] bettolad[420]: Generating world...
[ 4.100000] bettolad[420]: World generation complete. Seed: 0xDEADBEEF
[ 4.200000] bettolad[420]: Listening on 0.0.0.0:1337
[ 4.500000] systemd[1]: Started Bettola Daemon.
[ 4.800000] systemd[1]: Reached target Multi-User System.
[ 5.000000] systemd[1]: Starting Graphical Interface.
[ 5.500000] bettolac-greeter: Starting display manager...

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,34 @@
map -sV 10.0.2.15
ls -la /etc/
ps aux | grep 'sshd'
gcc exploit.c -o exploit
SELECT * FROM users;
import requests
while(true){};
});
sudo rm -rf /
netstat -anp
arp -a
ssh root@192.168.1.1
git commit -m 'Initial commit'
hydra -l user -P wordlist.txt ftp://...
Hello, world.
All your base are belong to us
// TODO: fix this later
42
1337
zerocool
acidburn
cereal_killer
ping -c 4 google.com
traceroute 8.8.8.8
</div>
<a>
password123
GOD
Have you tried turning it off and on again?
Connection established.
SYN/ACK
Firewall breach detected.
Decrypting /etc/shadow...
root::0:0:root:/root:/bin/bash

View File

@ -0,0 +1,27 @@
-- /bin/build - "Compiles" a .lua file into an executable.
local source_filename = arg[1]
if not source_filename then
return "build: missing file operand"
end
-- Check for .lua extension.
if not source_filename:match("%.lua$") then
return "build: input file must be a .lua file"
end
local current_dir = bettola.get_current_dir(context)
local source_node = current_dir.children[source_filename]
if not source_node then
return "build: cannot open '" .. source_filename .."': No such file"
end
if source_node.type ~= 0 then
return "build: '" .. source_filename .. "' is not a regular file"
end
local executable_filename = source_filename:gsub("%.lua$", "")
local source_content = source_node.content
return bettola.create_executable(context, executable_filename, source_content)

View File

@ -0,0 +1,19 @@
-- /bin/cat - Concatenate files and print on stdout.
local filename = arg[1]
if not filename then
return "" -- No arguments, return nothing.
end
local current_dir = bettola.get_current_dir(context)
local target_node = current_dir.children[filename]
if not target_node then
return "cat: " .. filename .. ": No such file or directory."
end
if target_node.type == 1 then
return "cat: " .. filename .. ": Is a directory"
end
-- It's a file, return it's contents. :)
return target_node.content

View File

@ -0,0 +1,7 @@
-- /bin/cd - Change Directory.
local target = arg[1]
if not target then
return "" -- No argument, just return to prompt.
end
return bettola.cd(context, target)

View File

@ -0,0 +1,25 @@
-- /bin/echo - Display contents of a text file.
local content_parts = {}
local filename = nil
local found_redirect = false
for i, v in ipairs(arg) do
if v == ">" then
found_redirect = true
filename = arg[i+1]
break
else
table.insert(content_parts, v)
end
end
if found_redirect then
if not filename then
return "echo: syntax error: expected filename after '>'"
end
local content = table.concat(content_parts, " ")
return bettola.write_file(context, filename, content)
else
return table.concat(arg, " ")
end

View File

@ -0,0 +1,6 @@
-- /bin/exit - Disconnects from a remote session or cloes terminal window.
if is_remote_session then
return bettola.disconnect(context)
else
return bettola.close_terminal(context)
end

62
assets/scripts/bin/ls.lua Normal file
View File

@ -0,0 +1,62 @@
-- /bin/ls - Lists files in a directory.
--
-- Iterate over the 'children' map exposed via C++.
local function format_permissions(perms)
local rwx = { "-", "-", "-", "-", "-", "-", "-", "-", "-" }
if(perms & 0x100) ~= 0 then rwx[1] = "r" end -- Owner read.
if(perms & 0x080) ~= 0 then rwx[2] = "w" end -- Owner write.
if(perms & 0x040) ~= 0 then rwx[3] = "x" end -- Owner execute.
if(perms & 0x020) ~= 0 then rwx[4] = "r" end -- Group read.
if(perms & 0x010) ~= 0 then rwx[5] = "w" end -- Group write.
if(perms & 0x008) ~= 0 then rwx[6] = "x" end -- Group execute.
if(perms & 0x004) ~= 0 then rwx[7] = "r" end -- Other read.
if(perms & 0x002) ~= 0 then rwx[8] = "w" end -- Other write.
if(perms & 0x001) ~= 0 then rwx[9] = "x" end -- Other execute.
return table.concat(rwx)
end
local function get_file_size(node)
if node.type == 0 then -- FILE_NODE.
return #node.content
else
return 0 -- Dirs don't have content size in this context.
end
end
local function ls_long_format(dir)
local output = {}
for name, node in pairs(dir.children) do
local line_type = (node.type == 1) and "d" or "-"
local perms = format_permissions(node.permissions)
local owner = node.owner_id
local group = node.group_id
local size = get_file_size(node)
table.insert(output, string.format("%s%s %d %d %5d %s", line_type, perms, owner, group, size, name))
end
table.sort(output)
return table.concat(output, "\n")
end
local function ls_short_format(dir)
local output = {}
for name, node in pairs(dir.children) do
local display_name = name
if node.type == 1 then -- DIR_NODE
display_name = display_name .. "/"
elseif node.type == 2 then --EXEC_NODE
display_name = display_name .. "*"
end
table.insert(output, display_name)
end
table.sort(output)
return table.concat(output, "\t") -- Tab separated short format.
end
local current_dir = bettola.get_current_dir(context);
if arg[1] == "-l" then
return ls_long_format(current_dir)
else
return ls_short_format(current_dir)
end

View File

@ -0,0 +1,9 @@
-- /bin/nmap - Network exploration tool and security/port scanner.
local target_ip = arg[1]
if not target_ip then
return "nmap: requires a target host to be specified"
end
-- TODO: Add args such as -sV for version detection etc.
return bettola.nmap(context, target_ip)

View File

@ -0,0 +1,3 @@
local file_to_remove = arg[1]
if not file_to_remove then return "rm: missing operand" end
return bettola.rm(context, file_to_remove)

View File

@ -0,0 +1,8 @@
local source = arg[1]
local destination = arg[2]
if not source or not destination then
return "usage: scp source destination"
end
return bettola.scp(context, source, destination)

View File

@ -0,0 +1,4 @@
-- /bin/ssh - Connects to a remote host.
local target = arg[1]
if not target then return "ssh: missing operand" end
return bettola.ssh(context, target)

View File

@ -1,85 +0,0 @@
#version 330 core
out vec4 FragColor;
in vec3 TexCoords;
uniform sampler2D u_CloudTexture;
uniform vec3 u_SunPos;
const float PI = 3.14159265359;
/* Atmosphere parameters. */
const float ATMOSPHERE_RADIUS = 6520275.0;
const float PLANET_RADIUS = 6371000.0;
const float RAYLEIGH_SCALE_HEIGHT = 8000.0;
const float MIE_SCALE_HEIGHT = 1200.0;
/* Scattering coefficients. */
const vec3 RAYLEIGH_SCATTERING_COEFF = vec3(0.0000058, 0.0000135, 0.0000331);
const vec3 MIE_SCATTERING_COEFF = vec3(0.00002);
/* Mie scattering parameters. */
const float MIE_G = 0.76;
/* Number of samples for ray marching. */
const int SAMPLES = 16;
const int LIGHT_SAMPLES = 8;
float ray_sphere_intersect(vec3 origin, vec3 dir, float radius) {
float a = dot(dir, dir);
float b = 2.0 * dot(origin, dir);
float c = dot(origin, origin) - radius * radius;
float d = b * b - 4.0 * a * c;
if (d < 0.0) {
return -1.0;
}
return (-b + sqrt(d)) / (2.0 * a);
}
vec3 get_sky_color(vec3 dir) {
vec3 eye = vec3(0.0, PLANET_RADIUS + 1.0, 0.0);
float dist = ray_sphere_intersect(eye, dir, ATMOSPHERE_RADIUS);
vec3 totalRayleigh = vec3(0.0);
vec3 totalMie = vec3(0.0);
float stepSize = dist / float(SAMPLES);
for (int i = 0; i < SAMPLES; i++) {
vec3 p = eye + dir * (float(i) + 0.5) * stepSize;
float h = length(p) - PLANET_RADIUS;
float rayleighDensity = exp(-h / RAYLEIGH_SCALE_HEIGHT);
float mieDensity = exp(-h / MIE_SCALE_HEIGHT);
float lightDist = ray_sphere_intersect(p, normalize(u_SunPos), ATMOSPHERE_RADIUS);
float lightStepSize = lightDist / float(LIGHT_SAMPLES);
float opticalDepthLight = 0.0;
for (int j = 0; j < LIGHT_SAMPLES; j++) {
vec3 lp = p + normalize(u_SunPos) * (float(j) + 0.5) * lightStepSize;
float lh = length(lp) - PLANET_RADIUS;
opticalDepthLight += exp(-lh / RAYLEIGH_SCALE_HEIGHT) * lightStepSize;
}
vec3 transmittance = exp(-(opticalDepthLight * RAYLEIGH_SCATTERING_COEFF
+ opticalDepthLight * MIE_SCATTERING_COEFF));
totalRayleigh += rayleighDensity * stepSize * transmittance;
totalMie += mieDensity * stepSize * transmittance;
}
float mu = dot(dir, normalize(u_SunPos));
float rayleighPhase = 3.0 / (16.0 * PI) * (1.0 + mu * mu);
float miePhase = 3.0 / (8.0 * PI) * ((1.0 - MIE_G * MIE_G)
* (1.0 + mu * mu)) / ((2.0 + MIE_G * MIE_G) * pow(1.0 + MIE_G
* MIE_G - 2.0 * MIE_G * mu, 1.5));
return 20.0 * (totalRayleigh * RAYLEIGH_SCATTERING_COEFF * rayleighPhase
+ totalMie * MIE_SCATTERING_COEFF * miePhase);
}
void main() {
float u = atan(TexCoords.z, TexCoords.x) / (2.0 * PI) + 0.5;
float v = asin(TexCoords.y) / PI + 0.5;
float cloudAlpha = texture(u_CloudTexture, vec2(u,v)).r;
vec3 skyColor = get_sky_color(normalize(TexCoords));
vec3 cloudColor = mix(skyColor, vec3(1.0), smoothstep(0.5, 0.8, cloudAlpha));
float poleFade = 1.0 - abs(TexCoords.y);
float finalAlpha = smoothstep(0.0, 0.2, poleFade) * smoothstep(0.5, 0.8, cloudAlpha);
FragColor = vec4(pow(cloudColor, vec3(1.0/2.2)), finalAlpha);
}

View File

@ -1,21 +0,0 @@
#version 330 core
layout (location = 0) in vec3 aPos;
out vec3 TexCoords;
uniform float u_Time;
uniform float u_ScrollSpeed;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main() {
TexCoords = aPos;
/* Rotate texture coords around Y axis for scrolling. */
float angle = u_Time * u_ScrollSpeed;
mat3 rot = mat3(cos(angle), 0, sin(angle), 0, 1, 0, -sin(angle), 0, cos(angle));
TexCoords = rot * TexCoords;
gl_Position = projection * view * model * vec4(aPos, 1.0);
}

View File

@ -0,0 +1,8 @@
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
void main() {
FragColor = vec4(ourColor, 1.0);
}

12
assets/shaders/shape.vert Normal file
View File

@ -0,0 +1,12 @@
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec3 aColor;
uniform mat4 projection;
out vec3 ourColor;
void main() {
gl_Position = projection * vec4(aPos.x, aPos.y, 0.0, 1.0);
ourColor = aColor;
}

View File

@ -1,50 +0,0 @@
#version 330 core
out vec4 FragColor;
in vec3 FragPos;
in vec3 Normal;
in vec3 WorldPos;
in float Detail;
uniform vec3 objectColor;
uniform vec3 lightColor;
uniform vec3 lightDir; /* Normalised direction vector for the light source. */
uniform bool u_IsTerrain;
void main() {
vec3 finalColor;
vec3 norm = normalize(Normal);
/* Define colours for different terrain types. */
if(u_IsTerrain) {
vec3 grassColor1 = vec3(0.4, 0.6, 0.2);
vec3 grassColor2 = vec3(0.3, 0.5, 0.15);
vec3 rockColor = vec3(0.5, 0.5, 0.4);
vec3 steepColor = vec3(0.35, 0.3, 0.25);
/* Create patchy grass colur using the detail noise. */
float detailFactor = smoothstep(-0.2, 0.2, Detail);
vec3 finalGrassColor = mix(grassColor1, grassColor2, detailFactor);
/* Blend from grass to rock between height of 2.0 and 4.0. */
float heightFactor = smoothstep(2.0, 4.0, WorldPos.y);
finalColor = mix(finalGrassColor, rockColor, heightFactor);
/* Flat surface has a normal.y of 1.0. A steep slope is closer to 0. */
float slopeFactor = 1.0 - smoothstep(0.6, 0.8, norm.y);
finalColor = mix(finalColor, steepColor, slopeFactor);
} else {
finalColor = objectColor;
}
/* Standard lighting calculations. */
float ambientStrength = 0.4;
vec3 ambient = ambientStrength * lightColor;
float diff = max(dot(norm, normalize(-lightDir)), 0.0);
vec3 diffuse = diff * lightColor;
vec3 result = (ambient + diffuse) * finalColor;
FragColor = vec4(result, 1.0);
}

View File

@ -1,23 +0,0 @@
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in float aDetail;
out vec3 FragPos;
out vec3 Normal;
out vec3 WorldPos;
out float Detail;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main() {
/* Pass position and normal vectors to the fragment shader in world space. */
FragPos = vec3(model * vec4(aPos, 1.0));
WorldPos = FragPos;
Normal = mat3(transpose(inverse(model))) * aNormal;
Detail = aDetail;
gl_Position = projection * view * vec4(FragPos, 1.0);
}

View File

@ -1,80 +0,0 @@
#version 330 core
out vec4 FragColor;
in vec3 v_WorldPos;
uniform vec3 u_SunPos;
const float PI = 3.14159265359;
/* Atmosphere parameters. */
const float ATMOSPHERE_RADIUS = 6520275.0;
const float PLANET_RADIUS = 6371000.0;
const float RAYLEIGH_SCALE_HEIGHT = 8000.0;
const float MIE_SCALE_HEIGHT = 1200.0;
/* Scattering coefficients. */
const vec3 RAYLEIGH_SCATTERING_COEFF = vec3(0.0000058, 0.0000135, 0.0000331);
const vec3 MIE_SCATTERING_COEFF = vec3(0.00002);
/* Mie scattering parameters. */
const float MIE_G = 0.76;
/* Number of samples for ray marching. */
const int SAMPLES = 16;
const int LIGHT_SAMPLES = 8;
float ray_sphere_intersect(vec3 origin, vec3 dir, float radius) {
float a = dot(dir, dir);
float b = 2.0 * dot(origin, dir);
float c = dot(origin, origin) - radius * radius;
float d = b * b - 4.0 * a * c;
if (d < 0.0) {
return -1.0;
}
return (-b + sqrt(d)) / (2.0 * a);
}
void main() {
vec3 dir = normalize(v_WorldPos);
vec3 eye = vec3(0.0, PLANET_RADIUS + 1.0, 0.0);
float dist = ray_sphere_intersect(eye, dir, ATMOSPHERE_RADIUS);
vec3 totalRayleigh = vec3(0.0);
vec3 totalMie = vec3(0.0);
float stepSize = dist / float(SAMPLES);
for(int i = 0; i < SAMPLES; i++) {
vec3 p = eye + dir * (float(i) + 0.5) * stepSize;
float h = length(p) - PLANET_RADIUS;
float rayleighDensity = exp(-h / RAYLEIGH_SCALE_HEIGHT);
float mieDensity = exp(-h / MIE_SCALE_HEIGHT);
float lightDist = ray_sphere_intersect(p, normalize(u_SunPos), ATMOSPHERE_RADIUS);
float lightStepSize = lightDist / float(LIGHT_SAMPLES);
float opticalDepthLight = 0.0;
for(int j = 0; j < LIGHT_SAMPLES; j++) {
vec3 lp = p + normalize(u_SunPos) * (float(j) + 0.5) * lightStepSize;
float lh = length(lp) - PLANET_RADIUS;
opticalDepthLight += exp(-lh / RAYLEIGH_SCALE_HEIGHT) * lightStepSize;
}
vec3 transmittance = exp(-(opticalDepthLight * RAYLEIGH_SCATTERING_COEFF
+ opticalDepthLight * MIE_SCATTERING_COEFF));
totalRayleigh += rayleighDensity * stepSize * transmittance;
totalMie += mieDensity * stepSize * transmittance;
}
float mu = dot(dir, normalize(u_SunPos));
float rayleighPhase = 3.0 / (16.0 * PI) * (1.0 + mu * mu);
float miePhase = 3.0 / (8.0 * PI) * ((1.0 - MIE_G * MIE_G)
* (1.0 + mu * mu)) / ((2.0 + MIE_G * MIE_G)
* pow(1.0 + MIE_G * MIE_G - 2.0 * MIE_G * mu, 1.5));
vec3 finalColor = 20.0 * (totalRayleigh * RAYLEIGH_SCATTERING_COEFF
* rayleighPhase + totalMie * MIE_SCATTERING_COEFF * miePhase);
FragColor = vec4(pow(finalColor, vec3(1.0/2.2)), 1.0);
}

View File

@ -1,13 +0,0 @@
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 view;
uniform mat4 projection;
out vec3 v_WorldPos;
void main() {
v_WorldPos = aPos;
vec4 pos = projection * view * vec4(aPos, 1.0);
gl_Position = pos.xyww;
}

11
assets/shaders/text.frag Normal file
View File

@ -0,0 +1,11 @@
#version 330 core
in vec2 TexCoords;
in vec3 ourColor;
out vec4 color;
uniform sampler2D text;
void main() {
vec4 sampled = vec4(1.0, 1.0, 1.0, texture(text, TexCoords).r);
color = vec4(ourColor, 1.0) * sampled;
}

15
assets/shaders/text.vert Normal file
View File

@ -0,0 +1,15 @@
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aTexCoords;
layout (location = 2) in vec3 aColor;
out vec2 TexCoords;
out vec3 ourColor;
uniform mat4 projection;
void main() {
gl_Position = projection * vec4(aPos, 0.0, 1.0);
TexCoords = aTexCoords;
ourColor = aColor;
}

25
client/CMakeLists.txt Normal file
View File

@ -0,0 +1,25 @@
file(GLOB_RECURSE CLIENT_SOURCES "src/*.cpp")
# Explicitly list server sources needed for single-player to avoid main() conflict.
set(SERVER_SOURCES
"../server/src/network_manager.cpp"
"../server/src/player.cpp"
)
add_executable(bettolac
${CLIENT_SOURCES}
${SERVER_SOURCES}
)
find_package(SDL3 REQUIRED)
find_package(GLEW REQUIRED)
find_package(OpenGL REQUIRED)
find_package(Freetype REQUIRED)
find_package(Threads REQUIRED)
target_link_libraries(bettolac PRIVATE bettola SDL3::SDL3 ${GLEW_LIBRARIES}
${OPENGL_LIBRARIES} ${FREETYPE_LIBRARIES} Threads::Threads)
target_include_directories(bettolac PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src
# Add server include dir for single player mode.
${CMAKE_SOURCE_DIR}/server/src
${GLEW_INCLUDE_DIRS} ${OPENGL_INCLUDE_DIR} ${FREETYPE_INCLUDE_DIRS})

View File

@ -0,0 +1,101 @@
#include <cstdio>
#include <functional>
#include <string>
#include "net/tcp_connection.h"
#include "client_network.h"
ClientNetwork::ClientNetwork(void) : _io_context() {}
ClientNetwork::~ClientNetwork(void) {
disconnect();
}
bool ClientNetwork::connect(const std::string& host, uint16_t port) {
try {
/* Resolve hostname into list of endpoints. */
asio::ip::tcp::resolver resolver(_io_context);
asio::ip::tcp::resolver::results_type endpoints =
resolver.resolve(host, std::to_string(port));
/* Create connection object. It will own a socket. */
_connection = std::make_shared<net::TcpConnection>(_io_context,
asio::ip::tcp::socket(_io_context));
/* Connect to server. */
asio::connect(_connection->socket(), endpoints);
if(!_connection->socket().is_open()) {
return false;
}
/* Define callbacks for the connection. */
_connection->start(
[this](const std::string& msg) {
/* Push new messages to the thread safe queue. */
this->_incoming_messages.push_back(msg);
},
[this]() {
/* Reset our connection on server disconnect. */
fprintf(stderr, "[BettolaClient] Server disconnected.\n");
this->_connection.reset();
});
/* Start io_context in background thread. */
_context_thread = std::thread([this]() { _io_context.run(); });
} catch(const std::exception& e) {
fprintf(stderr, "[BettolaClient] Connection exception: %s\n", e.what());
return false;
}
return true;
}
void ClientNetwork::disconnect(void) {
/* Stop the context, should cause the io_context.run() call in the
* background thread to return.
*/
_io_context.stop();
/* Wait for the background thread to finish. */
if(_context_thread.joinable()) {
_context_thread.join();
}
/* Should be safe to close the socket now that thread is stoped <questionmark> */
if(is_connected()) {
/* Close the socket. Causes outstanding async operations
* in TcpConnection to compete with an error, which in return
* triggers the on_disconnect callback.
*/
_connection->socket().close();
}
/* Kill background thread. */
_io_context.stop();
if(_context_thread.joinable()) {
_context_thread.join();
}
/* Release connection object. */
_connection.reset();
}
bool ClientNetwork::is_connected(void) {
return _connection && _connection->socket().is_open();
}
void ClientNetwork::send(const std::string& message) {
if(is_connected()) {
_connection->send(message);
}
}
bool ClientNetwork::poll_message(std::string& message) {
if(_incoming_messages.empty()) {
return false;
}
message = _incoming_messages.pop_front();
return true;
}

View File

@ -0,0 +1,30 @@
#pragma once
#include <asio.hpp>
#include <memory.h>
#include <string>
#include <thread>
#include "asio/io_context.hpp"
#include "net/tcp_connection.h"
#include "net/ts_queue.h"
class ClientNetwork {
public:
ClientNetwork(void);
~ClientNetwork(void);
bool connect(const std::string& host, uint16_t port);
void disconnect(void);
bool is_connected(void);
void send(const std::string& message);
bool poll_message(std::string& message);
private:
asio::io_context _io_context;
std::thread _context_thread;
std::shared_ptr<net::TcpConnection> _connection;
TsQueue<std::string> _incoming_messages;
};

View File

@ -0,0 +1,52 @@
#include <cstdio>
#include "debug_overlay.h"
#include "ui/ui_renderer.h"
#include "gfx/types.h"
DebugOverlay::DebugOverlay(void) :
_fps(0.0f),
_frame_time(0.0f),
_draw_calls(0),
_shape_vertices(0),
_text_vertices(0),
_update_timer(0.0f) {}
void DebugOverlay::update(float dt, int draw_calls, int shape_verts, int txt_verts) {
_update_timer += dt;
/* Only update a few times per sec to keep them readable. */
if(_update_timer > 0.2f) {
_update_timer = 0.0f;
_frame_time = dt * 1000.0f;
_fps = 1.0f / dt;
_draw_calls = draw_calls;
_shape_vertices = shape_verts;
_text_vertices = txt_verts;
}
}
void DebugOverlay::render(UIRenderer* ui_renderer) {
const Color text_color = { 0.0f, 1.0f, 0.0f }; /* Bright green. */
const float line_height = 18.0f;
const float padding = 5.0f;
char buffer[256];
/* Manages its own rendering batch. */
ui_renderer->begin_shapes();
ui_renderer->begin_text();
snprintf(buffer, sizeof(buffer), "FPS: %.0f (%.2f ms)", _fps, _frame_time);
ui_renderer->render_text(buffer, padding, padding+line_height*1, text_color);
snprintf(buffer, sizeof(buffer), "Draw Calls: %d", _draw_calls);
ui_renderer->render_text(buffer, padding, padding+line_height*2, text_color);
snprintf(buffer, sizeof(buffer), "Vertices: %d (Shape %d, Text: %d)",
_shape_vertices + _text_vertices, _shape_vertices, _text_vertices);
ui_renderer->render_text(buffer, padding, padding+line_height*3, text_color);
ui_renderer->flush_shapes();
ui_renderer->flush_text();
}

View File

@ -0,0 +1,24 @@
#pragma once
class UIRenderer;
/**
* Renders real-time performance metrics on screen.
*/
class DebugOverlay {
public:
DebugOverlay(void);
void update(float dt, int draw_calls, int shape_verts, int txt_verts);
void render(UIRenderer* ui_renderer);
private:
float _fps;
float _frame_time;
int _draw_calls;
int _shape_vertices;
int _text_vertices;
/* Oh? You want to be able to read the stats?! */
float _update_timer;
};

View File

@ -0,0 +1,5 @@
#include "debug_stats.h"
int DebugStats::draw_calls = 0;
int DebugStats::shape_vertices = 0;
int DebugStats::text_vertices = 0;

View File

@ -0,0 +1,14 @@
#pragma once
struct DebugStats {
static int draw_calls;
static int shape_vertices;
static int text_vertices;
static void reset(void) {
draw_calls = 0;
shape_vertices = 0;
text_vertices = 0;
}
};

371
client/src/game_state.cpp Normal file
View File

@ -0,0 +1,371 @@
#include <cstdio>
#include <memory>
#include <sstream>
#include <string>
#include <thread>
#include <chrono>
#include "gfx/types.h"
#include "net/constants.h"
#include "net/message_protocol.h"
#include "client_network.h"
#include "network_manager.h"
#include "game_state.h"
#include "terminal.h"
#include "ui/desktop.h"
#include "ui/ui_window.h"
#include "ui/editor.h"
#include "ui/login_screen.h"
#include <SDL3/SDL_events.h>
#include <ui/main_menu.h>
#include <ui/boot_sequence.h>
#include "debug/debug_overlay.h"
void GameState::_init_desktop(void) {
_desktop = std::make_unique<Desktop>(_screen_width, _screen_height, this, _initial_session_id);
auto term = std::make_unique<Terminal>(this);
term->set_session_id(_initial_session_id);
auto term_window = std::make_unique<UIWindow>("Terminal", 100, 100, 800, 500);
term_window->set_session_id(_initial_session_id);
UIWindow* window_ptr = term_window.get();
term_window->set_content(std::move(term));
_desktop->add_window(std::move(term_window));
_desktop->register_session(_initial_session_id, window_ptr);
}
void GameState::_run_server(void) {
try {
NetworkManager server("bettola_sp.db");
server.start(SINGLE_PLAYER_PORT);
/*
* Server's start() method is non-blocking, but NetworkManager
* object must be kept alive. We'll just loop forever and let the OS
* clean up the thread when main exits ;)
*/
while(true) {
std::this_thread::sleep_for(std::chrono::seconds(5));
}
} catch(const std::exception& e) {
fprintf(stderr, "Single-player server thread exception: %s\n", e.what());
}
}
GameState::GameState(void) :
_current_screen(Screen::MAIN_MENU),
_screen_width(0),
_screen_height(0),
_is_single_player(false),
_show_debug_overlay(false),
_initial_session_id(0) {_debug_overlay = std::make_unique<DebugOverlay>();}
GameState::~GameState(void) = default;
void GameState::init(int screen_width, int screen_height) {
_screen_width = screen_width;
_screen_height = screen_height;
/* Create and connect the network client. */
_network = std::make_unique<ClientNetwork>();
_main_menu = std::make_unique<MainMenu>(screen_width, screen_height);
}
void GameState::start_single_player_now(int screen_width, int screen_height) {
_is_single_player = true;
_screen_width = screen_width;
_screen_height = screen_height;
fprintf(stdout, "Starting in single-player mode...\n");
std::thread server_thread(&GameState::_run_server, this);
server_thread.detach();
std::this_thread::sleep_for(std::chrono::milliseconds(200));
_network = std::make_unique<ClientNetwork>();
if(!_network->connect("127.0.0.1", SINGLE_PLAYER_PORT)) {
/* TODO: Handle connection failure. */
}
_current_screen = Screen::DESKTOP;
_init_desktop();
}
void GameState::handle_event(SDL_Event* event, int screen_width, int screen_height) {
if(event->type == SDL_EVENT_KEY_DOWN && event->key.key == SDLK_D &&
(event->key.mod & SDL_KMOD_CTRL)) {
_show_debug_overlay = !_show_debug_overlay;
return; /* Consume the event. */
}
switch(_current_screen) {
case Screen::MAIN_MENU:
if(_main_menu) {
_main_menu->handle_event(event);
}
break;
case Screen::LOGIN:
if(_login_screen) {
_login_screen->handle_event(event);
}
break;
case Screen::BOOTING:
/* TODO: */
break;
case Screen::DESKTOP:
if(_desktop) {
_desktop->handle_event(event, screen_width, screen_height);
}
break;
}
}
void GameState::update(float dt, int draw_calls, int shape_verts, int text_verts) {
_debug_overlay->update(dt, draw_calls, shape_verts, text_verts);
switch(_current_screen) {
case Screen::MAIN_MENU: {
if(!_main_menu) break;
Screen next_screen = _main_menu->update(dt);
if(next_screen != Screen::MAIN_MENU) {
const MenuButton* clicked_button = _main_menu->get_clicked_button();
if(clicked_button) {
_is_single_player = clicked_button->is_single_player;
}
_current_screen = next_screen;
_main_menu.reset(); /* Free mem. */
if(_current_screen == Screen::LOGIN) {
/* Connect to server. */
if(_is_single_player) {
fprintf(stdout, "Starting in single-player mode...\n");
std::thread server_thread(&GameState::_run_server, this);
server_thread.detach();
std::this_thread::sleep_for(std::chrono::milliseconds(200));
if(!_network->connect("127.0.0.1", SINGLE_PLAYER_PORT)) {
/* TODO: Handle connection failure. */
}
} else {
if(!_network->connect("127.0.0.1", MULTIPLAYER_PORT)) {
/* TODO: Handle connection failure. */
}
}
_login_screen = std::make_unique<LoginScreen>(_screen_width, _screen_height);
}
}
break;
}
case Screen::LOGIN: {
if(_login_screen && _login_screen->is_login_attempted()) {
std::string username = _login_screen->get_username();
std::string password = _login_screen->get_password();
if(_login_screen->is_new_account_mode()) {
std::string hostname = _login_screen->get_hostname();
_network->send(net_protocol::build_message(net_protocol::Opcode::C2S_CREATE_ACCOUNT,
{username, password, hostname}));
} else {
_network->send(net_protocol::build_message(net_protocol::Opcode::C2S_LOGIN,
{username, password}));
}
_login_screen->clear_login_attempt(); /* Try to spam my server now b.tch! */
}
/* Check for server response to our login attempt. */
std::string server_msg;
while(_network->poll_message(server_msg)) {
net_protocol::Opcode opcode;
std::vector<std::string> args;
net_protocol::parse_message(server_msg, opcode, args);
switch(opcode) {
case net_protocol::Opcode::S2C_LOGIN_SUCCESS:
case net_protocol::Opcode::S2C_CREATE_ACCOUNT_SUCCESS:
/* Don't switch screen yet, wait for the session ID. */
break;
case net_protocol::Opcode::S2C_SESSION_CREATED:
if(!args.empty()) {
_initial_session_id = std::stoul(args[0]);
}
_current_screen = Screen::BOOTING;
_login_screen.reset(); /* Free mem. */
_boot_sequence = std::make_unique<BootSequence>();
break;
case net_protocol::Opcode::S2C_LOGIN_FAIL:
_login_screen->set_error_message(args.empty() ? "Login failed." : args[0]);
break;
case net_protocol::Opcode::S2C_CREATE_ACCOUNT_FAIL:
_login_screen->set_error_message(args.empty() ? "Account creation failed." :
args[0]);
break;
default:
fprintf(stderr, "Recieved unexpected opcode %d during login.\n",
static_cast<int>(opcode));
break;
}
/* If we successfully changed screen, stop processing messages for this frame. */
if(_current_screen == Screen::BOOTING) {
break;
}
}
break;
}
case Screen::BOOTING: {
if(!_boot_sequence) break; /* Shouldn't happen. */
if(_boot_sequence->is_finished()) {
_current_screen = Screen::DESKTOP;
_init_desktop();
_boot_sequence.reset(); /* Free mem. */
}
break;
}
case Screen::DESKTOP: {
std::string server_msg;
while(_network->poll_message(server_msg)) {
net_protocol::Opcode opcode;
std::vector<std::string> args;
net_protocol::parse_message(server_msg, opcode, args);
switch(opcode) {
case net_protocol::Opcode::S2C_SESSION_CREATED:
if(!args.empty()) {
uint32_t session_id = std::stoul(args[0]);
UIWindow* new_term = _desktop->get_window_awaiting_session_id();
if(new_term) {
Terminal* term = dynamic_cast<Terminal*>(new_term->get_content());
if(term) {
new_term->set_session_id(session_id);
term->set_session_id(session_id);
_desktop->register_session(session_id, new_term);
}
}
}
break;
case net_protocol::Opcode::S2C_FILE_DATA:
if(args.size() == 3) {
uint32_t session_id = std::stoul(args[0]);
auto editor = std::make_unique<Editor>(args[1]);
editor->set_buffer_content(args[2]);
auto editor_window = std::make_unique<UIWindow>(args[1].c_str(),
200, 200, 600, 400);
editor_window->set_session_id(session_id);
editor_window->set_content(std::move(editor));
_desktop->add_window(std::move(editor_window));
}
break;
case net_protocol::Opcode::S2C_DISCONNECT: {
if(!args.empty()) {
uint32_t session_id = std::stoul(args[0]);
UIWindow* window = _desktop->get_window_by_session_id(session_id);
if(window) {
Terminal* terminal = dynamic_cast<Terminal*>(window->get_content());
if(terminal) {
terminal->add_history("Connection closed.");
}
}
}
}
break;
case net_protocol::Opcode::S2C_CLOSE_WINDOW: {
if(!args.empty()) {
uint32_t session_id = std::stoul(args[0]);
UIWindow* window = _desktop->get_window_by_session_id(session_id);
if(window) {
window->close();
}
}
}
break;
case net_protocol::Opcode::S2C_COMMAND_RESPONSE: {
if(args.size() == 2) {
uint32_t session_id = std::stoul(args[0]);
UIWindow* window = _desktop->get_window_by_session_id(session_id);
if(window) {
Terminal* terminal = dynamic_cast<Terminal*>(window->get_content());
if(terminal) {
/* Server sends "output\nprompt", split them. */
size_t last_newline = args[1].find_last_of('\n');
if(last_newline != std::string::npos) {
std::string prompt = args[1].substr(last_newline+1);
terminal->set_prompt(prompt);
std::string output = args[1].substr(0, last_newline);
if(!output.empty()) {
/* Split multiline output and push each line to history. */
std::stringstream ss(output);
std::string line;
while(std::getline(ss, line, '\n')) {
terminal->add_history(line);
}
}
} else {
terminal->add_history(args[1]);
}
}
}
}
}
break;
}
}
if(_desktop) {
_desktop->update(dt, _screen_width, _screen_height);
}
break;
}
}
}
void GameState::render(const RenderContext& context) {
switch(_current_screen) {
case Screen::MAIN_MENU:
if(_main_menu) {
_main_menu->render(context.ui_renderer);
}
break;
case Screen::LOGIN:
if(_login_screen) {
_login_screen->render(context);
}
break;
case Screen::BOOTING:
if(_boot_sequence) {
_boot_sequence->render(context.ui_renderer);
}
break;
case Screen::DESKTOP:
if(_desktop) {
_desktop->render(context);
}
break;
}
if(_show_debug_overlay) {
_debug_overlay->render(context.ui_renderer);
}
}
void GameState::send_network_command(uint32_t session_id, const std::string& command) {
if(_network && _network->is_connected()) {
_network->send(net_protocol::build_message(net_protocol::Opcode::C2S_COMMAND,
{std::to_string(session_id), command}));
}
}
void GameState::send_file_write_request(uint32_t session_id, const std::string& path,
const std::string& content) {
_network->send(net_protocol::build_message(net_protocol::Opcode::C2S_WRITE_FILE,
{std::to_string(session_id), path, content}));
}
void GameState::send_file_read_request(uint32_t session_id, const std::string& path) {
_network->send(net_protocol::build_message(net_protocol::Opcode::C2S_READ_FILE,
{std::to_string(session_id), path}));
}
void GameState::send_build_file_request(uint32_t session_id, const std::string& path,
const std::string& content) {
_network->send(net_protocol::build_message(net_protocol::Opcode::C2S_BUILD_FILE,
{std::to_string(session_id), path, content}));
}
void GameState::send_create_session_request(void) {
if(_network && _network->is_connected()) {
_network->send(net_protocol::build_message(net_protocol::Opcode::C2S_CREATE_SESSION));
}
}

58
client/src/game_state.h Normal file
View File

@ -0,0 +1,58 @@
#pragma once
#include <memory>
#include "gfx/types.h"
class DebugOverlay;
class ClientNetwork;
class Desktop;
class LoginScreen;
class BootSequence;
class MainMenu;
class ShapeRenderer;
class TextRenderer;
union SDL_Event;
enum class Screen {
LOGIN,
MAIN_MENU,
BOOTING,
DESKTOP
};
class GameState {
public:
GameState(void);
~GameState(void);
void init(int screen_width, int screen_height);
void start_single_player_now(int screen_width, int screen_height);
void handle_event(SDL_Event* event, int screen_width, int screen_height);
void update(float dt, int draw_calls, int shape_verts, int text_verts);
void render(const RenderContext& context);
/* Public network interface for UI components. */
void send_network_command(uint32_t session_id, const std::string& command);
void send_file_write_request(uint32_t session_id, const std::string& path, const std::string& content);
void send_build_file_request(uint32_t session_id, const std::string& path, const std::string& content);
void send_file_read_request(uint32_t session_id, const std::string& path);
void send_create_session_request(void);
private:
std::unique_ptr<ClientNetwork> _network;
std::unique_ptr<Desktop> _desktop;
std::unique_ptr<BootSequence> _boot_sequence;
std::unique_ptr<LoginScreen> _login_screen;
std::unique_ptr<MainMenu> _main_menu;
std::unique_ptr<DebugOverlay> _debug_overlay;
bool _show_debug_overlay;
Screen _current_screen;
int _screen_width;
int _screen_height;
bool _is_single_player;
uint32_t _initial_session_id;
void _init_desktop(void);
void _run_server(void);
};

86
client/src/gfx/shader.cpp Normal file
View File

@ -0,0 +1,86 @@
#include <cstdio>
#include <cstring>
#include <fstream>
#include <sstream>
#include <string>
#include <GL/glew.h>
#include "shader.h"
std::string read_file(const char* path) {
std::ifstream file(path);
if(!file.is_open()) {
printf("Failed to open file: %s\n", path);
return "";
}
std::stringstream buffer;
buffer << file.rdbuf();
return buffer.str();
}
Shader::Shader(const char* vert_path, const char* frag_path) {
std::string vert_string = read_file(vert_path);
std::string frag_string = read_file(frag_path);
if(vert_string.empty() || frag_string.empty()) {
return;
}
const char* vert_source = vert_string.c_str();
const char* frag_source = frag_string.c_str();
unsigned int vert, frag;
vert = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vert, 1, &vert_source, NULL);
glCompileShader(vert);
_check_compile_errors(vert, "VERTEX");
frag = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(frag, 1, &frag_source, NULL);
glCompileShader(frag);
_check_compile_errors(frag, "FRAGMENT");
id = glCreateProgram();
glAttachShader(id, vert);
glAttachShader(id, frag);
glLinkProgram(id);
_check_compile_errors(id, "PROGRAM");
glDeleteShader(vert);
glDeleteShader(frag);
}
void Shader::use(void) {
glUseProgram(id);
}
void Shader::set_mat4(const char* name, const float* value) {
glUniformMatrix4fv(glGetUniformLocation(id, name), 1, GL_FALSE, value);
}
void Shader::set_vec3(const char* name, float v1, float v2, float v3) {
glUniform3f(glGetUniformLocation(id, name), v1, v2, v3);
}
void Shader::set_int(const char* name, int value) {
glUniform1i(glGetUniformLocation(id, name), value);
}
void Shader::_check_compile_errors(unsigned int shader_id, const char* type) {
int success;
char info_log[1024];
if(strcmp(type, "PROGRAM") != 0) {
glGetShaderiv(shader_id, GL_COMPILE_STATUS, &success);
if(!success) {
glGetShaderInfoLog(shader_id, 1024, NULL, info_log);
printf("Shader compilation error (%s):\n%s\n", type, info_log);
}
} else {
glGetProgramiv(shader_id, GL_LINK_STATUS, &success);
if(!success) {
glGetProgramInfoLog(shader_id, 1024, NULL, info_log);
printf("Shader linking error (%s):\n%s\n", type, info_log);
}
}
}

16
client/src/gfx/shader.h Normal file
View File

@ -0,0 +1,16 @@
#pragma once
class Shader {
public:
unsigned int id;
Shader(const char* vertex_path, const char* fragment_path);
void use(void);
void set_mat4(const char* name, const float* value);
void set_vec3(const char* name, float v1, float v2, float v3);
void set_int(const char* name, int value);
private:
void _check_compile_errors(unsigned int shader, const char* type);
};

View File

@ -0,0 +1,82 @@
#include <GL/glew.h>
#include "shape_renderer.h"
#include "debug/debug_stats.h"
#include "math/math.h"
ShapeRenderer::ShapeRenderer(unsigned int screen_width, unsigned int screen_height) {
math::ortho_proj(_projection, 0.0f, (float)screen_width, 0.0f, (float)screen_height, -1.0f, 1.0f);
/* Load shader. */
_shape_shader = new Shader("assets/shaders/shape.vert",
"assets/shaders/shape.frag");
_shape_shader->use();
_shape_shader->set_mat4("projection", _projection);
/* Configure VAO/VBO for batch rendering. */
glGenVertexArrays(1, &_vao);
glGenBuffers(1, &_vbo);
glBindVertexArray(_vao);
glBindBuffer(GL_ARRAY_BUFFER, _vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(ShapeVertex) * MAX_SHAPE_VERTICES, nullptr, GL_DYNAMIC_DRAW);
/* Position attribute. */
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(ShapeVertex), (void*)0);
/* Colour attribute. */
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(ShapeVertex), (void*)offsetof(ShapeVertex, r));
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
_vertices.reserve(MAX_SHAPE_VERTICES);
}
ShapeRenderer::~ShapeRenderer(void) {
delete _shape_shader;
}
void ShapeRenderer::begin(void) {
_vertices.clear();
}
void ShapeRenderer::flush(void) {
if(_vertices.empty()) return;
_shape_shader->use();
glBindVertexArray(_vao);
glBindBuffer(GL_ARRAY_BUFFER, _vbo);
glBufferSubData(GL_ARRAY_BUFFER, 0, _vertices.size() * sizeof(ShapeVertex), _vertices.data());
DebugStats::draw_calls++;
DebugStats::shape_vertices += _vertices.size();
glDrawArrays(GL_TRIANGLES, 0, _vertices.size());
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
}
void ShapeRenderer::draw_rect(int x, int y, int width, int height, const Color& color) {
float x_f = (float)x;
float y_f = (float)y;
float w_f = (float)width;
float h_f = (float)height;
_vertices.push_back({x_f, y_f+h_f, color.r, color.g, color.b});
_vertices.push_back({x_f, y_f, color.r, color.g, color.b});
_vertices.push_back({x_f+w_f, y_f, color.r, color.g, color.b});
_vertices.push_back({x_f, y_f+h_f, color.r, color.g, color.b});
_vertices.push_back({x_f+w_f, y_f, color.r, color.g, color.b});
_vertices.push_back({x_f+w_f, y_f+h_f, color.r, color.g, color.b});
}
void ShapeRenderer::draw_triangle(int x1, int y1, int x2, int y2, int x3,
int y3, const Color& color) {
_vertices.push_back({(float)x1, (float)y1, color.r, color.g, color.b});
_vertices.push_back({(float)x2, (float)y2, color.r, color.g, color.b});
_vertices.push_back({(float)x3, (float)y3, color.r, color.g, color.b});
}

View File

@ -0,0 +1,33 @@
#pragma once
#include <vector>
#include "shader.h"
#include "types.h"
struct ShapeVertex {
float x, y, r, g, b;
};
const int MAX_SHAPES_PER_BATCH = 10000;
const int VERTICES_PER_RECT = 6;
const int MAX_SHAPE_VERTICES = MAX_SHAPES_PER_BATCH * VERTICES_PER_RECT;
class ShapeRenderer {
public:
ShapeRenderer(unsigned int screen_width, unsigned int screen_height);
~ShapeRenderer(void);
void begin(void);
void flush(void);
void draw_rect(int x, int y, int width, int height, const Color& color);
void draw_triangle(int x1, int y1, int x2, int y2, int x3, int y3,
const Color& color);
private:
Shader* _shape_shader;
unsigned int _vao, _vbo;
std::vector<ShapeVertex> _vertices;
float _projection[16];
};

View File

@ -0,0 +1,186 @@
#include <cstdio>
#include "gfx/types.h"
#include <GL/glew.h>
#include <SDL3/SDL_render.h>
#include <freetype/freetype.h>
#include "txt_renderer.h"
#include "debug/debug_stats.h"
#include "math/math.h"
TextRenderer::TextRenderer(unsigned int screen_width, unsigned int screen_height) {
/* Create projection matrix. */
math::ortho_proj(_projecton, 0.0f, (float)screen_width, 0.0f, (float)screen_height, -1.0f, 1.0f);
/* Load shader. */
_txt_shader = new Shader("assets/shaders/text.vert",
"assets/shaders/text.frag");
_txt_shader->use();
_txt_shader->set_mat4("projection", _projecton);
/* Configure VAO/VBO for batch rendering. */
glGenVertexArrays(1, &_vao);
glGenBuffers(1, &_vbo);
glBindVertexArray(_vao);
glBindBuffer(GL_ARRAY_BUFFER, _vbo);
/* Pre-allocate buffer memory. */
glBufferData(GL_ARRAY_BUFFER, sizeof(TextVertex) * MAX_VERTICES, nullptr, GL_DYNAMIC_DRAW);
/* Position attribute. */
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(TextVertex), (void*)0);
/* Texture coord attribute. */
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(TextVertex), (void*)offsetof(TextVertex, s));
/* Colour attribute. */
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, sizeof(TextVertex), (void*)offsetof(TextVertex, r));
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
/* Pre-allocate vector capacity. */
_vertices.reserve(MAX_VERTICES);
}
TextRenderer::~TextRenderer(void) {
delete _txt_shader;
glDeleteTextures(1, &_atlas_texture_id);
}
void TextRenderer::load_font(const char* font_path, unsigned int font_size) {
FT_Library ft;
if(FT_Init_FreeType(&ft)) {
printf("Could not init FreeType Library\n");
return;
}
FT_Face face;
if(FT_New_Face(ft, font_path, 0, &face)) {
printf("Failed to load font: %s\n", font_path);
return;
}
FT_Set_Pixel_Sizes(face, 0, font_size);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
/* Calculate atlas dimensions. */
unsigned int atlas_width = 0;
unsigned int atlas_height = 0;
unsigned int max_height = 0;
for(unsigned char c = 0; c < 128; c++) {
if(FT_Load_Char(face, c, FT_LOAD_RENDER)) {
printf("Failed to load Glyph for char %c\n", c);
continue;
}
atlas_width += face->glyph->bitmap.width;
max_height = std::max(max_height, face->glyph->bitmap.rows);
}
atlas_height = max_height;
/* Create the atlas texture. */
glGenTextures(1, &_atlas_texture_id);
glBindTexture(GL_TEXTURE_2D, _atlas_texture_id);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, atlas_width, atlas_height, 0, GL_RED,
GL_UNSIGNED_BYTE, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
/* Fill the atlas texture with data. */
int x_offset = 0;
for(unsigned char c = 0; c < 128; c++) {
if(FT_Load_Char(face, c, FT_LOAD_RENDER)) {
continue;
}
/* Copy glyph bitmap to atlas. */
glTexSubImage2D(
GL_TEXTURE_2D, 0, x_offset, 0,
face->glyph->bitmap.width, face->glyph->bitmap.rows,
GL_RED, GL_UNSIGNED_BYTE, face->glyph->bitmap.buffer
);
/* Store character metrics and texture coords. */
_chars[c] = {
{(int)face->glyph->bitmap.width, (int)face->glyph->bitmap.rows },
{face->glyph->bitmap_left, face->glyph->bitmap_top},
(unsigned int)face->glyph->advance.x,
(float)x_offset / atlas_width,
0.0f,
(float)face->glyph->bitmap.width / atlas_width,
(float)face->glyph->bitmap.rows / atlas_height
};
x_offset += face->glyph->bitmap.width;
}
glBindTexture(GL_TEXTURE_2D, 0);
FT_Done_Face(face);
FT_Done_FreeType(ft);
}
void TextRenderer::begin(void) {
_vertices.clear();
}
void TextRenderer::flush(void) {
if(_vertices.empty()) {
return;
}
_txt_shader->use();
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, _atlas_texture_id);
glBindVertexArray(_vao);
glBindBuffer(GL_ARRAY_BUFFER, _vbo);
glBufferSubData(GL_ARRAY_BUFFER, 0, _vertices.size() * sizeof(TextVertex), _vertices.data());
DebugStats::draw_calls++;
DebugStats::text_vertices += _vertices.size();
glDrawArrays(GL_TRIANGLES, 0, _vertices.size());
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
glBindTexture(GL_TEXTURE_2D, 0);
}
void TextRenderer::render_text(const char* text, float x, float y, const Color& color) {
for(const char* p = text; *p; p++) {
unsigned char c = *p;
character ch = _chars[c];
float xpos = x + ch.bearing[0];
float ypos = y - (ch.size[1] - ch.bearing[1]);
float w = ch.size[0];
float h = ch.size[1];
_vertices.push_back({xpos, ypos+h, ch.tx, ch.ty, color.r, color.g, color.b});
_vertices.push_back({xpos, ypos, ch.tx, ch.ty+ch.th, color.r, color.g, color.b});
_vertices.push_back({xpos+w, ypos, ch.tx+ch.tw, ch.ty+ch.th, color.r, color.g, color.b});
_vertices.push_back({xpos, ypos+h, ch.tx, ch.ty, color.r, color.g, color.b});
_vertices.push_back({xpos+w, ypos, ch.tx+ch.tw, ch.ty+ch.th, color.r, color.g, color.b});
_vertices.push_back({xpos+w, ypos+h, ch.tx+ch.tw, ch.ty, color.r, color.g, color.b});
x += (ch.advance >> 6);
}
}
float TextRenderer::get_text_width(const char* text, float scale) {
float width = 0.0f;
for(const char* p = text; *p; p++) {
unsigned char c = *p;
if(c < 128) {
width += (_chars[c].advance >> 6) * scale;
}
}
return width;
}

View File

@ -0,0 +1,47 @@
#pragma once
#include <ft2build.h>
#include <vector>
#include FT_FREETYPE_H
#include "shader.h"
#include "types.h"
struct TextVertex {
float x, y, s, t, r, g, b;
};
/* State of a single charactrer glyph. */
struct character {
int size[2];
int bearing[2];
unsigned int advance;
float tx, ty; /* x and y offset of glyph in texture atlas. */
float tw, th; /* width and height of glyph in texture atlas. */
};
const int MAX_GLYPHS_PER_BATCH = 10000;
const int VERTICES_PER_GLYPH = 6;
const int MAX_VERTICES = MAX_GLYPHS_PER_BATCH * VERTICES_PER_GLYPH;
class TextRenderer {
public:
TextRenderer(unsigned int screen_width, unsigned int screen_height);
~TextRenderer(void);
void load_font(const char* font_path, unsigned int font_size);
void render_text(const char* text, float x, float y, const Color& color);
float get_text_width(const char* text, float scale);
void begin(void);
void flush(void);
private:
Shader* _txt_shader;
unsigned int _vao, _vbo;
unsigned int _atlas_texture_id;
character _chars[128];
std::vector<TextVertex> _vertices;
float _projecton[16];
};

17
client/src/gfx/types.h Normal file
View File

@ -0,0 +1,17 @@
#pragma once
class UIRenderer;
struct Color {
float r, g, b;
};
struct Rect {
int x, y, w, h;
};
struct RenderContext {
UIRenderer* ui_renderer;
int screen_height;
bool show_cursor;
};

164
client/src/main.cpp Normal file
View File

@ -0,0 +1,164 @@
#include <memory>
#include <cstdio>
#include <string>
#include <GL/glew.h>
#include <SDL3/SDL.h>
#include <SDL3/SDL_keyboard.h>
#include <SDL3/SDL_timer.h>
#include "db/db.h"
#include "gfx/shape_renderer.h"
#include "gfx/txt_renderer.h"
#include "game_state.h"
#include "ui/ui_renderer.h"
#include "gfx/types.h"
#include "ui/cursor_manager.h"
#include "debug/debug_stats.h"
const int SCREEN_WIDTH = 1280;
const int SCREEN_HEIGHT = 720;
int main(int argc, char** argv) {
/* Init SDL. */
if(!SDL_Init(SDL_INIT_VIDEO)) {
printf("SDL could not initialise! SDL_ERROR: %s\n", SDL_GetError());
return 1;
}
/* Set OpenGL attributes. */
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
/* Create a window. */
SDL_Window* window = SDL_CreateWindow(
"Bettola Client",
SCREEN_WIDTH,
SCREEN_HEIGHT,
SDL_WINDOW_OPENGL
);
if(window == NULL) {
printf("Unable to create window! SDL_ERROR: %s\n", SDL_GetError());
return 1;
}
/* Create OpenGL context. */
SDL_GLContext context = SDL_GL_CreateContext(window);
if(context == NULL) {
printf("OpenGL context could not be created! SDL_ERROR: %s\n", SDL_GetError());
return 1;
}
/* Initialise GLEW. */
glewExperimental = GL_TRUE;
GLenum glewError = glewInit();
if (glewError != GLEW_OK) {
/* NOTE:
* Dear future self, or other intrepid developer:
* In some environments (like the Fedora VM I tested),
* glewInit() returns an error here even when the context is valid.
* This is a known quirk. We print the error but continue anyway,
* because it's not fatal. Don't "fix" this by making it exit. Thank you.
* It's GLEW, not you.
*/
fprintf(stderr, "Warning: glewInit failed with error code %u: %s. Attempting to continue anyway.\n",
glewError, glewGetErrorString(glewError));
}
/* Configure OpenGL. */
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
/* Listen for text input. */
SDL_StartTextInput(window);
/* Init cursor manager. */
CursorManager::init();
/* Init text renderer. */
TextRenderer* txt_render_instance = new TextRenderer(SCREEN_WIDTH, SCREEN_HEIGHT);
txt_render_instance->load_font("assets/fonts/hack/Hack-Regular.ttf", 14);
/* Init shape renderer. */
ShapeRenderer* shape_renderer_instance = new ShapeRenderer(SCREEN_WIDTH, SCREEN_HEIGHT);
/* Init UI renderer. */
UIRenderer* ui_renderer_instance = new UIRenderer(shape_renderer_instance,
txt_render_instance, SCREEN_HEIGHT);
auto game_state = std::make_unique<GameState>();
if(argc > 1 && std::string(argv[1]) == "-sp") {
game_state->start_single_player_now(SCREEN_WIDTH, SCREEN_HEIGHT);
} else {
game_state->init(SCREEN_WIDTH, SCREEN_HEIGHT);
}
/* timer for cursor blink. */
Uint32 last_blink_time = 0;
bool show_cursor = true;
/* Timestep. */
Uint64 last_frame_time = SDL_GetPerformanceCounter();
float dt = 0.0f;
bool running = true;
while(running) {
/* Reset per-frame stats. */
DebugStats::reset();
/* Event handling. */
SDL_Event event;
while(SDL_PollEvent(&event)) {
if(event.type == SDL_EVENT_QUIT) { running = false; }
game_state->handle_event(&event, SCREEN_WIDTH, SCREEN_HEIGHT);
}
/* Calculate delta time. */
Uint64 current_frame_time = SDL_GetPerformanceCounter();
dt = (current_frame_time - last_frame_time) / (float)SDL_GetPerformanceFrequency();
last_frame_time = current_frame_time;
/* Clamp dt to avoid large jumps. */
if(dt > 0.1f) dt = 0.1f;
Uint32 current_time = SDL_GetTicks();
if(current_time - last_blink_time > 500) { /* Every 500ms. */
show_cursor = !show_cursor;
last_blink_time = current_time;
}
/* Clear screen. */
glClearColor(0.04f, 0.05f, 0.06, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
RenderContext context = {
ui_renderer_instance,
SCREEN_HEIGHT,
show_cursor
};
game_state->render(context);
/* Update game state after rendering so stats are for the frame just rendered.
* I know this is unusual, but given the text based nature of the game
* we won't see a negative impact, and this is better than a large refactor ;)
*/
game_state->update(dt, DebugStats::draw_calls, DebugStats::shape_vertices,
DebugStats::text_vertices);
/* It's really odd to call it SwapWindow now, rather than SwapBuffer. */
SDL_GL_SwapWindow(window);
}
/* Cleanup. */
game_state.reset();
delete ui_renderer_instance;
delete shape_renderer_instance;
delete txt_render_instance;
SDL_GL_DestroyContext(context);
CursorManager::quit();
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}

218
client/src/terminal.cpp Normal file
View File

@ -0,0 +1,218 @@
#include <cstdio>
#include <memory>
#include <sstream>
#include <SDL3/SDL.h>
#include <GL/glew.h>
#include <SDL3/SDL_events.h>
#include "terminal.h"
#include "game_state.h"
#include "gfx/types.h"
#include "ui/editor.h"
#include "ui/window_action.h"
Terminal::Terminal(GameState* game_state)
: _game_state(game_state), _should_close(false), _command_history_index(0),
_scroll_offset(0), _prompt(""), _pending_action({ActionType::NONE}),
_session_id(0) {
_input_view = std::make_unique<TextView>(&_input_buffer, false, false, false);
}
Terminal::~Terminal(void) {}
void Terminal::update(int content_width, int content_height) {
}
void Terminal::add_history(const std::string& line) {
std::string line_with_spaces;
for(char ch : line) {
if(ch == '\t') {
line_with_spaces += " ";
} else {
line_with_spaces += ch;
}
}
_history.push_back(line_with_spaces);
if(line == "__CLOSE_CONNECTION__") {
_should_close = true;
}
}
void Terminal::set_prompt(const std::string& prompt) {
_prompt = prompt;
}
bool Terminal::should_close(void) {
return _should_close;
}
WindowAction Terminal::get_pending_action(void) {
WindowAction action = _pending_action;
_pending_action.type = ActionType::NONE;
return action;
}
void Terminal::set_session_id(uint32_t id) {
_session_id = id;
}
uint32_t Terminal::get_session_id(void) const {
return _session_id;
}
void Terminal::_on_ret_press(int content_height) {
std::string command = _input_buffer.get_line(0);
if(!command.empty()) {
_command_history.push_back(command);
}
_command_history_index = _command_history.size();
_input_buffer.clear();
if(command == "clear") {
_history.push_back(_prompt + "> " + command);
_history.clear();
return;
}
/* Client-side command handling. */
std::stringstream ss(command);
std::string cmd_name;
ss >> cmd_name;
if(cmd_name == "edit") {
_history.push_back(_prompt + "> " + command);
ss >> _pending_action.payload1; /* filename. */
_pending_action.type = ActionType::READ_FILE;
return;
}
_history.push_back(_prompt + "> " + command);
_game_state->send_network_command(_session_id, command);
/* After processing a command, always snap to the bottom. */
float line_height = 20.0f;
int visible_lines = content_height / line_height;
int max_scroll = (_history.size()+1) - visible_lines;
if(max_scroll < 0) max_scroll = 0;
_scroll_offset = max_scroll;
}
void Terminal::handle_input(SDL_Event* event, int window_x, int window_y, int window_gl_y,
int content_width, int content_height) {
/* Pass input to TextView; if true, RET was pressed. */
if(event->type == SDL_EVENT_KEY_DOWN) {
switch(event->key.key) {
case SDLK_UP:
if(!_command_history.empty()) {
_command_history_index = std::max(0, _command_history_index-1);
_input_buffer.set_text(_command_history[_command_history_index]);
}
break;
case SDLK_DOWN:
if(!_command_history.empty()) {
_command_history_index =
std::min((int)_command_history.size(), _command_history_index+1);
if(_command_history_index < (int)_command_history.size()) {
_input_buffer.set_text(_command_history[_command_history_index]);
} else {
_input_buffer.clear();
}
}
break;
default:
float line_height = 20.0f;
int visible_lines = content_height / line_height;
int max_scroll = (_history.size()+1) - visible_lines;
if(max_scroll < 0) max_scroll = 0;
if(_input_view->handle_event(event, content_height)) { _on_ret_press(content_height); }
else {
_scroll_offset = max_scroll; /* Always snap to bottom on key press. */
}
}
} else if(event->type == SDL_EVENT_TEXT_INPUT) {
if(_input_view->handle_event(event, content_height)) { _on_ret_press(content_height); }
else {
float line_height = 20.0f;
int visible_lines = content_height / line_height;
int max_scroll = (_history.size()+1) - visible_lines;
if(max_scroll < 0) max_scroll = 0;
_scroll_offset = max_scroll; /* Always snap to bottom on text input. */
}
}
}
void Terminal::scroll(int amount, int win_content_height) {
/* amount > 0 = scroll up. amount < 0 = scroll down. */
_scroll_offset += amount;
/* Lower bound: Don't scroll below the top of the history. */
if(_scroll_offset < 0) {
_scroll_offset = 0;
}
/* Upper bound: Don't scroll past the last command. */
float line_height = 20.0f;
int visible_lines = win_content_height / line_height;
int max_scroll = (_history.size()+1) - visible_lines;
if(max_scroll < 0) max_scroll = 0;
if(_scroll_offset > max_scroll) {
_scroll_offset = max_scroll;
}
}
void Terminal::render(const RenderContext& context, int x, int y_screen, int y_gl,
int width, int height) {
const Color white = { 1.0f, 1.0f, 1.0f };
const Color green = { 0.2f, 1.0f, 0.2f };
float line_height = 20.0f;
float padding = 5.0f;
/*
* Auto-scroll to bottom if the user has not manually scrolled up.
* Ensures input line is always visible after submitting a command.
*/
int visible_lines = (height-padding) / line_height;
int max_scroll_offset = (_history.size() + 1) - visible_lines;
if(max_scroll_offset < 0) max_scroll_offset = 0;
if(_scroll_offset >= max_scroll_offset - 1) _scroll_offset = max_scroll_offset;
context.ui_renderer->begin_text();
/* Enable scissor test to clip rendering to the window content area. */
glEnable(GL_SCISSOR_TEST);
glScissor(x, y_gl, width, height);
/* Draw History. */
for(size_t i = _scroll_offset; i < _history.size(); ++i) {
float line_y_pos = y_screen + padding + ((i - _scroll_offset) * line_height);
/* Culling: If line is already below the view, stop. */
if(line_y_pos > y_screen + height) { break; }
context.ui_renderer->render_text(_history[i].c_str(), x+padding, line_y_pos+18, white);
}
/* Draw current input line. */
float prompt_line_y = (y_screen + padding) + ((_history.size() - _scroll_offset) * line_height);
float prompt_baseline_y = prompt_line_y + 18;
/* Render prompt string. */
std::string prompt_str = _prompt + "> ";
context.ui_renderer->render_text(prompt_str.c_str(), x+padding, prompt_baseline_y, green);
/* Render text view for the input right after prompt. */
float input_x_pos = x + padding + (prompt_str.length() * 8.5f); /* Estimate width */
float input_width = width - (input_x_pos-x);
SyntaxTheme theme; /* Terminal doesn't need highlighting, just a default theme. */
_input_view->render_text_content(context.ui_renderer, theme, input_x_pos, prompt_line_y, input_width, line_height);
context.ui_renderer->flush_text();
if(context.show_cursor) {
context.ui_renderer->begin_shapes();
_input_view->render_cursor(context.ui_renderer, theme, input_x_pos, prompt_line_y, input_width, line_height);
context.ui_renderer->flush_shapes();
}
/* Disable scissor test. */
glDisable(GL_SCISSOR_TEST);
}

47
client/src/terminal.h Normal file
View File

@ -0,0 +1,47 @@
#pragma once
#include <string>
#include <vector>
#include <memory>
#include <SDL3/SDL.h>
#include "gfx/types.h"
#include "ui/text_buffer.h"
#include "ui/text_view.h"
#include "ui/i_window_content.h"
#include "ui/window_action.h"
class GameState;
class Terminal : public IWindowContent {
public:
Terminal(GameState* game_state);
~Terminal(void);
void update(int content_width, int content_height) override;
void handle_input(SDL_Event* event, int window_x, int window_y, int window_gl_y,
int content_width, int content_height) override;
void render(const RenderContext& context, int x, int y_screen, int y_gl,
int width, int height) override;
void scroll(int amount, int content_height) override;
void add_history(const std::string& line);
void set_prompt(const std::string& prompt);
bool should_close(void) override;
WindowAction get_pending_action(void) override;
void set_session_id(uint32_t id);
uint32_t get_session_id(void) const;
private:
void _on_ret_press(int content_height);
bool _should_close;
std::vector<std::string> _command_history;
int _command_history_index;
std::vector<std::string> _history;
int _scroll_offset;
std::string _prompt;
GameState* _game_state;
TextBuffer _input_buffer;
WindowAction _pending_action;
uint32_t _session_id;
std::unique_ptr<TextView> _input_view;
};

View File

@ -0,0 +1,58 @@
#include <fstream>
#include <string>
#include <vector>
#include <cstdio>
#include "ui/ui_renderer.h"
#include "boot_sequence.h"
#include <SDL3/SDL_timer.h>
BootSequence::BootSequence(void) {
/* Load boot messages. */
std::ifstream boot_file("assets/boot_messages.txt");
if(!boot_file.is_open()) {
printf("ERROR: Failed to open assets/boot_messages.txt\n");
_messages.push_back("ERROR: boot_messages.txt not found.");
} else {
std::string line;
while(std::getline(boot_file, line)) {
if(!line.empty()) {
_messages.push_back(line);
}
}
}
/* Init timings. */
_start_time = SDL_GetTicks();
_line_interval_ms = 150; /* 150ms between each line. */
/* Total duration is time for all lines plus a 2-second pause at the end. */
_total_duration_ms = (_messages.size() * _line_interval_ms) + 2000;
}
BootSequence::~BootSequence(void) {}
bool BootSequence::is_finished(void) {
return (SDL_GetTicks() - _start_time) >= _total_duration_ms;
}
void BootSequence::render(UIRenderer* ui_renderer) {
ui_renderer->begin_text();
const Color text_color = { 0.9f, 0.9f, 0.9f }; /* grey/white */
const float line_height = 18.0f;
const float padding = 15.0f;
Uint32 elapsed_time = SDL_GetTicks() - _start_time;
int lines_to_show = elapsed_time / _line_interval_ms;
if(lines_to_show > _messages.size()) {
lines_to_show = _messages.size();
}
for(int i = 0; i < lines_to_show; ++i) {
float y_pos = padding + (i*line_height);
ui_renderer->render_text(_messages[i].c_str(), padding, y_pos, text_color);
}
ui_renderer->flush_text();
}

View File

@ -0,0 +1,21 @@
#pragma once
#include <vector>
#include <string>
#include <SDL3/SDL.h>
#include "ui/ui_renderer.h"
class BootSequence {
public:
BootSequence(void);
~BootSequence(void);
bool is_finished(void);
void render(UIRenderer* ui_renderer);
private:
std::vector<std::string> _messages;
Uint32 _start_time;
Uint32 _line_interval_ms; /* Time for each line to appear. */
Uint32 _total_duration_ms; /* Total time for the whole sequence. */
};

View File

@ -0,0 +1,39 @@
#include <cstdio>
#include "cursor_manager.h"
#include <SDL3/SDL_error.h>
#include <SDL3/SDL_mouse.h>
SDL_Cursor* CursorManager::_arrow_cursor = nullptr;
SDL_Cursor* CursorManager::_resize_cursor = nullptr;
void CursorManager::init(void) {
_arrow_cursor = SDL_CreateSystemCursor(SDL_SYSTEM_CURSOR_DEFAULT);
_resize_cursor = SDL_CreateSystemCursor(SDL_SYSTEM_CURSOR_NWSE_RESIZE);
if(!_arrow_cursor || !_resize_cursor) {
printf("Failed to create system cursors! SDL_Error: %s\n", SDL_GetError());
}
}
void CursorManager::set_cursor(CursorType type) {
SDL_Cursor* cursor_to_set = _arrow_cursor;
switch(type) {
case CursorType::ARROW:
cursor_to_set = _arrow_cursor;
break;
case CursorType::RESIZE_NWSE:
cursor_to_set = _resize_cursor;
break;
}
if(SDL_GetCursor() != cursor_to_set) {
SDL_SetCursor(cursor_to_set);
}
}
void CursorManager::quit(void) {
SDL_DestroyCursor(_arrow_cursor);
SDL_DestroyCursor(_resize_cursor);
}

View File

@ -0,0 +1,19 @@
#pragma once
#include <SDL3/SDL_mouse.h>
enum class CursorType {
ARROW,
RESIZE_NWSE /* Diagonal resize arrow. */
};
class CursorManager {
public:
static void init(void);
static void set_cursor(CursorType type);
static void quit(void);
private:
static SDL_Cursor* _arrow_cursor;
static SDL_Cursor* _resize_cursor;
};

324
client/src/ui/desktop.cpp Normal file
View File

@ -0,0 +1,324 @@
#include <algorithm>
#include <ctime>
#include <fstream>
#include <cmath>
#include <memory>
#include <ui/cursor_manager.h>
#include <SDL3/SDL_events.h>
#include <SDL3/SDL_video.h>
#include "gfx/types.h"
#include "game_state.h"
#include "desktop.h"
#include "terminal.h"
#include "ui/i_window_content.h"
#include "ui/launcher.h"
#include "ui/taskbar.h"
#include "ui/ui_window.h"
#include "ui/editor.h"
#include "ui/window_action.h"
static const std::string& get_random_snippet(const std::vector<std::string>& snippets) {
if(snippets.empty()) {
static const std::string err = "ERR";
return err;
}
return snippets[rand() % snippets.size()];
}
Desktop::Desktop(int screen_width, int screen_height, GameState* game_state,
uint32_t initial_session_id) {
_taskbar = std::make_unique<Taskbar>(screen_width, screen_height);
_initial_session_id = initial_session_id;
_game_state= game_state;
_focused_window = nullptr;
_window_awaiting_session_id = nullptr;
_launcher_is_open = false;
_launcher = std::make_unique<Launcher>(5, 0, 200); /* Tmp y-coord. */
int launcher_y = screen_height - _taskbar->get_height() - _launcher->get_height();
_launcher->set_y(launcher_y);
/* Load snippets for temp wallpaper. */
std::ifstream snippet_file("assets/menu_background_snippets.txt");
if(snippet_file.is_open()) {
std::string line;
while(std::getline(snippet_file, line)) {
if(!line.empty()) { _snippets.push_back(line); }
}
}
/* Init animated background. */
for(int i = 0; i < 100; ++i) {
float y_pos = (float)(rand() % screen_height);
_background_text.push_back(
{get_random_snippet(_snippets), (float)(rand() % screen_width),
y_pos,
(float)(rand() % 30 + 10) / 100.0f,
(int)y_pos
});
}
}
/* Desktop owns UIWindow, make sure we delete them. */
Desktop::~Desktop(void) {}
void Desktop::add_window(std::unique_ptr<UIWindow> window) {
UIWindow* window_ptr = window.get();
_windows.push_back(std::move(window));
_taskbar->add_window(window_ptr);
_set_focused_window(window_ptr);
}
void Desktop::_set_focused_window(UIWindow* window) {
if(window == _focused_window) {
return;
}
/* Unfocus old window. */
if(_focused_window) {
_focused_window->set_focused(false);
}
/* Set new focused window. */
_focused_window = window;
if(_focused_window) {
_focused_window->set_focused(true);
/* Move newly focused window to end of vector so it's rendered on top. */
auto it = std::find_if(_windows.begin(), _windows.end(),
[window](const std::unique_ptr<UIWindow>& p) {
return p.get() == window;
});
if(it != _windows.end()) {
std::rotate(it, it+1, _windows.end());
}
}
}
void Desktop::handle_event(SDL_Event* event, int screen_width, int screen_height) {
if(event->type == SDL_EVENT_MOUSE_BUTTON_DOWN) {
int mouse_x = event->button.x;
int mouse_y = event->button.y;
/* If click is on taskbar, ignore it on the down-event. Let the up-event handle it. */
if(_taskbar->is_point_inside(mouse_x, mouse_y)) {
return;
}
/* When launcher open, check for outside click to close it. */
if(_launcher_is_open && !_launcher->is_point_inside(mouse_x, mouse_y, screen_height)) {
_launcher_is_open = false;
return;
}
/* If click not on focused window, find which window should receive focus. */
bool click_on_window = false;
for(int i = _windows.size()-1; i >= 0; --i) {
if(_windows[i].get()->is_point_inside(mouse_x, mouse_y)) {
/* Top most window. Focus it. */
UIWindow* target = _windows[i].get();
if(!target->is_minimized()) {
_set_focused_window(target);
/* Pass event down to newly focused window to handle dragging, etc. */
target->handle_event(event, screen_width, screen_height, _taskbar->get_height());
}
click_on_window = true;
break;
}
}
/* Click wasn't on a window. Unfocus. */
if(!click_on_window) {
_set_focused_window(nullptr);
}
} else if(event->type == SDL_EVENT_MOUSE_BUTTON_UP) {
if(_taskbar->is_start_button_clicked(event, screen_height)) {
_launcher_is_open = !_launcher_is_open;
} else if(_launcher_is_open) {
std::string app_to_launch = _launcher->handle_event(event, screen_height);
if(app_to_launch == "Terminal") {
auto term = std::make_unique<Terminal>(_game_state);
auto term_window = std::make_unique<UIWindow>("Terminal", 150, 150, 800, 500);
_window_awaiting_session_id = term_window.get();
term_window->set_content(std::move(term));
add_window(std::move(term_window));
_launcher_is_open = false;
_game_state->send_create_session_request();
} else if(app_to_launch == "Editor") {
auto editor = std::make_unique<Editor>();
auto editor_window = std::make_unique<UIWindow>("Editor", 200, 200, 600, 400);
editor_window->set_session_id(_initial_session_id);
editor_window->set_content(std::move(editor));
add_window(std::move(editor_window));
_launcher_is_open = false;
}
} else {
UIWindow* clicked_window = _taskbar->handle_event(event, screen_height);
if(clicked_window) {
if(clicked_window == _focused_window && !clicked_window->is_minimized()) {
clicked_window->minimize();
_set_focused_window(nullptr);
} else {
clicked_window->restore();
_set_focused_window(clicked_window);
}
}
}
} else if(event->type == SDL_EVENT_MOUSE_MOTION) {
/* Update cursor if hovering over any resize handle. */
for(int i = _windows.size() - 1; i >= 0; --i) {
if(_windows[i]->is_mouse_over_resize_handle(event->motion.x,
event->motion.y)) {
return CursorManager::set_cursor(CursorType::RESIZE_NWSE);
}
}
CursorManager::set_cursor(CursorType::ARROW);
}
/* Pass all other non-down-click events to focused window. */
if(_focused_window && event->type != SDL_EVENT_MOUSE_BUTTON_DOWN) {
_focused_window->handle_event(event, screen_width, screen_height, _taskbar->get_height());
}
}
void Desktop::update(float dt, int screen_width, int screen_height) {
/* Remove closed windows. */
if(_focused_window) {
IWindowContent* content = _focused_window->get_content();
if(content) {
WindowAction action = content->get_pending_action();
uint32_t session_id = _focused_window->get_session_id();
switch(action.type) {
case ActionType::WRITE_FILE: {
if(session_id != 0) {
_game_state->send_file_write_request(session_id, action.payload1, action.payload2);
}
break;
}
case ActionType::READ_FILE: {
if(session_id != 0) {
_game_state->send_file_read_request(session_id, action.payload1);
}
break;
}
case ActionType::BUILD_FILE: {
if(session_id != 0) {
_game_state->send_build_file_request(session_id, action.payload1, action.payload2);
}
break;
}
default:
break;
}
}
}
for(auto& window : _windows) {
if(window) {
window->update();
}
}
_update_wallpaper(dt, screen_width, screen_height);
_windows.erase(std::remove_if(_windows.begin(), _windows.end(),
[this](const std::unique_ptr<UIWindow>& w) {
if (w->should_close()) {
/* Also remove from session map. */
uint32_t session_to_remove = 0;
for(auto const& [sid, win] : _session_windows) {
if(win == w.get()) {
session_to_remove = sid;
break;
}
}
if(session_to_remove != 0)
_session_windows.erase(session_to_remove);
_taskbar->remove_window(w.get());
if (w.get() == _focused_window) {
_focused_window = nullptr;
}
return true;
}
return false;
}),
_windows.end());
}
void Desktop::register_session(uint32_t session_id, UIWindow* window) {
_session_windows[session_id] = window;
if(_window_awaiting_session_id == window) {
_window_awaiting_session_id = nullptr;
}
}
UIWindow* Desktop::get_window_by_session_id(uint32_t session_id) {
if(_session_windows.count(session_id)) {
return _session_windows.at(session_id);
}
return nullptr;
}
UIWindow* Desktop::get_focused_window(void) {
return _focused_window;
}
UIWindow* Desktop::get_window_awaiting_session_id(void) {
return _window_awaiting_session_id;
}
void Desktop::render(const RenderContext& context) {
/* Pass 1: Background. */
context.ui_renderer->begin_text();
_render_wallpaper(context.ui_renderer);
context.ui_renderer->flush_text();
/* Pass 2: Windows Render in order, last window is top-most. */
for(size_t i = 0; i < _windows.size(); ++i) {
auto& win_i = _windows[i];
if(win_i->is_minimized()) continue;
bool is_occluded = false;
Rect rect_i = win_i->get_rect();
/* Check against all windows on top of this one. */
for(size_t j = i+1; j < _windows.size(); ++j) {
auto& win_j = _windows[j];
if(win_j->is_minimized()) continue;
Rect rect_j = win_j->get_rect();
if(rect_i.x >= rect_j.x && (rect_i.x + rect_i.w) <= (rect_j.x + rect_j.w) &&
rect_i.y >= rect_j.y && (rect_i.y + rect_i.h) <= (rect_j.y + rect_j.h)) {
is_occluded = true;
break;
}
}
if(!is_occluded) {
win_i->render(context);
}
}
/* Pass 3: Top-level static UI (taskbar, Launcher, etc.). */
context.ui_renderer->begin_text();
if(_launcher_is_open) {
_launcher->render(context.ui_renderer);
}
_taskbar->render(context.ui_renderer, _focused_window);
context.ui_renderer->flush_text();
}
void Desktop::_render_wallpaper(UIRenderer* ui_renderer) {
const Color wallpaper_color = { 0.0f, 0.15f, 0.08f };
for(auto& line : _background_text) {
ui_renderer->render_text(line.text.c_str(), std::round(line.x),
line.render_y, wallpaper_color);
}
}
void Desktop::_update_wallpaper(float dt, int screen_width, int screen_height) {
for(auto& line : _background_text) {
line.y_precise -= line.speed * dt * 100.0f;
line.render_y = static_cast<int>(std::round(line.y_precise));
if(line.render_y < 0) {
line.y_precise = screen_height;
line.x = (float)(rand() % screen_width);
line.text = get_random_snippet(_snippets);
}
}
}

57
client/src/ui/desktop.h Normal file
View File

@ -0,0 +1,57 @@
#pragma once
#include <memory>
#include <vector>
#include <map>
#include <SDL3/SDL.h>
#include "gfx/types.h"
#include "ui/ui_window.h"
#include "ui/taskbar.h"
#include "ui/launcher.h"
#include "ui/ui_renderer.h"
class GameState;
/* Animated background stuff. */
struct ScrollingText {
std::string text;
float x;
float y_precise; /* sub-pixel animation. */
float speed;
int render_y; /* For pixel-perfect rendering. */
};
class Desktop {
public:
Desktop(int screen_width, int screen_height, GameState* game_state,
uint32_t initial_session_id);
~Desktop(void);
void add_window(std::unique_ptr<UIWindow> window);
void handle_event(SDL_Event* event, int screen_width, int screen_height);
void update(float dt, int screen_width, int screen_height);
void render(const RenderContext& context);
void register_session(uint32_t session_id, UIWindow* window);
UIWindow* get_window_by_session_id(uint32_t session_id);
UIWindow* get_focused_window(void);
UIWindow* get_window_awaiting_session_id(void);
private:
void _set_focused_window(UIWindow* window);
void _render_wallpaper(UIRenderer* ui_renderer);
void _update_wallpaper(float dt, int screen_width, int screen_height);
std::vector<std::unique_ptr<UIWindow>> _windows;
std::unique_ptr<Taskbar> _taskbar;
std::unique_ptr<Launcher> _launcher;
UIWindow* _focused_window;
UIWindow* _window_awaiting_session_id;
std::map<uint32_t, UIWindow*> _session_windows;
GameState* _game_state;
std::vector<ScrollingText> _background_text;
std::vector<std::string> _snippets;
bool _launcher_is_open;
uint32_t _initial_session_id;
};

114
client/src/ui/editor.cpp Normal file
View File

@ -0,0 +1,114 @@
#include "editor.h"
#include <memory>
#include "text_view.h"
#include "gfx/types.h"
#include "ui/window_action.h"
Editor::Editor(void)
: _should_close(false), _pending_action({ActionType::NONE}),
_filename("untitled.txt") {
_view = std::make_unique<TextView>(&_buffer, true, true, true);
_menu_bar = std::make_unique<MenuBar>(25);
_menu_bar->add_menu("File");
_menu_bar->add_menu_item("File", "Save", [this]() {
_pending_action = {ActionType::WRITE_FILE, _filename, _buffer.get_text()};
});
_menu_bar->add_menu("Build");
_menu_bar->add_menu_item("Build", "Run Build", [this]() {
_pending_action = {ActionType::BUILD_FILE, _filename, _buffer.get_text()};
});
}
Editor::Editor(const std::string& filename)
: _should_close(false), _pending_action({ActionType::NONE}),
_filename(filename) {
_view = std::make_unique<TextView>(&_buffer, true, true, true);
_menu_bar = std::make_unique<MenuBar>(25);
_menu_bar->add_menu("File");
_menu_bar->add_menu_item("File", "Save", [this]() {
_pending_action = {ActionType::WRITE_FILE, _filename, _buffer.get_text()};
});
_menu_bar->add_menu("Build");
_menu_bar->add_menu_item("Build", "Run Build", [this]() {
_pending_action = {ActionType::BUILD_FILE, _filename, _buffer.get_text()};
});
}
Editor::~Editor(void) {}
void Editor::update(int content_width, int content_height) {
/* Nothing to do yet. */
}
void Editor::handle_input(SDL_Event* event, int window_x, int window_y, int window_gl_y,
int content_width, int content_height) {
if(!event) return;
_menu_bar->handle_event(event, window_x, window_y);
/* We don't care about the return val here. RET is just newline. */
if(event->type == SDL_EVENT_KEY_DOWN && event->key.key == SDLK_S &&
(event->key.mod & SDL_KMOD_CTRL)) {
/* C-S pressed, create a save action. */
_pending_action = { ActionType::WRITE_FILE, _filename, _buffer.get_text() };
} else {
_view->handle_event(event, content_height);
}
}
void Editor::render(const RenderContext& context, int x, int y_screen, int y_gl,
int width, int height) {
int menu_bar_height = _menu_bar->get_height();
int content_y = y_screen + menu_bar_height;
int content_height = height - menu_bar_height;
/* Pass 1: Main Bar Background.*/
context.ui_renderer->begin_shapes();
_menu_bar->render_bar_bg(context.ui_renderer, x, y_screen, width);
context.ui_renderer->flush_shapes();
context.ui_renderer->enable_scissor(x, y_gl, width, content_height);
/* Pass 2: Main text view. */
context.ui_renderer->begin_text();
_view->render_text_content(context.ui_renderer, _theme, x, content_y, width, content_height);
context.ui_renderer->flush_text();
/* Pass 3: Editor cursor. */
if(context.show_cursor) {
context.ui_renderer->begin_shapes();
_view->render_cursor(context.ui_renderer, _theme, x, content_y, width, content_height);
context.ui_renderer->flush_shapes();
}
context.ui_renderer->disable_scissor();
/* Pass 4: Menu bar text and dropdown. */
context.ui_renderer->begin_text();
_menu_bar->render_bar_text(context.ui_renderer, x, y_screen, width);
_menu_bar->render_dropdown(context.ui_renderer, x, y_screen, width);
context.ui_renderer->flush_text();
}
void Editor::scroll(int amount, int content_height) {
_view->scroll(amount, content_height);
}
bool Editor::should_close(void) {
return _should_close;
}
void Editor::set_buffer_content(const std::string& content) {
_buffer.set_text(content);
}
WindowAction Editor::get_pending_action(void) {
WindowAction action = _pending_action;
_pending_action.type = ActionType::NONE; /* Clear action. */
return action;
}
const std::string& Editor::get_filename(void) const {
return _filename;
}

47
client/src/ui/editor.h Normal file
View File

@ -0,0 +1,47 @@
#pragma once
#include <memory>
#include "gfx/types.h"
#include "i_window_content.h"
#include "ui/text_buffer.h"
#include "ui/window_action.h"
#include "ui/menu_bar.h"
class TextView;
struct SyntaxTheme {
Color normal = { 1.0f, 1.0f, 1.0f };
Color keyword = { 0.8f, 0.6f, 1.0f };
Color string = { 1.0f, 0.8f, 0.6f };
Color number = { 0.6f, 1.0f, 0.8f };
Color comment = { 0.6f, 0.6f, 0.6f };
Color line_num = { 0.5f, 0.6f, 0.7f };
};
class Editor : public IWindowContent {
public:
Editor(void);
Editor(const std::string& filename);
~Editor(void) override;
void update(int content_width, int content_height) override;
void handle_input(SDL_Event* event, int window_x, int window_y, int window_gl_y,
int content_widht, int content_heigth) override;
void render(const RenderContext& context, int x, int y_screen, int y_gl,
int width, int height) override;
void scroll(int amount, int content_height) override;
bool should_close(void) override;
void set_buffer_content(const std::string& content);
WindowAction get_pending_action(void) override;
const std::string& get_filename(void) const;
private:
TextBuffer _buffer;
std::unique_ptr<TextView> _view;
std::unique_ptr<MenuBar> _menu_bar;
bool _should_close;
WindowAction _pending_action;
SyntaxTheme _theme;
std::string _filename;
};

View File

@ -0,0 +1,19 @@
#pragma once
#include <SDL3/SDL_events.h>
#include "window_action.h"
#include "gfx/types.h"
class IWindowContent {
public:
virtual ~IWindowContent(void) = default;
virtual void update(int content_width, int content_height) = 0;
virtual void handle_input(SDL_Event* event, int window_x, int window_y, int window_gl_y,
int content_width, int content_height) = 0;
virtual void render(const RenderContext& context, int x, int y_screen, int y_gl,
int width, int height) = 0;
virtual void scroll(int amount, int content_height) = 0;
virtual bool should_close(void) = 0;
virtual WindowAction get_pending_action() = 0;
};

View File

@ -0,0 +1,76 @@
#include "launcher.h"
#include <SDL3/SDL_mouse.h>
#include "ui/ui_renderer.h"
Launcher::Launcher(int x, int y, int width) : _x(x), _y(y), _width(width) {
/* TODO: Hardcode the launcher apps for now. */
_apps.push_back("Terminal");
_apps.push_back("Editor");
int item_height = 30;
_height = _apps.size() * item_height;
}
Launcher::~Launcher(void) {}
bool Launcher::is_point_inside(int x, int y, int screen_height) const {
return (x >= _x && x <= _x + _width && y>= _y && y <= _y + _height);
}
void Launcher::render(UIRenderer* ui_renderer) {
const Color bg_color = { 0.15f, 0.17f, 0.19f };
const Color text_color = { 0.9f, 0.9f, 0.9f };
const Color hover_color = { 0.3f, 0.32f, 0.34f };
ui_renderer->begin_shapes();
ui_renderer->begin_text();
/* Note: y-coord is TOP of launcher menu. */
ui_renderer->draw_rect(_x, _y, _width, _height, bg_color);
int item_height = 30;
int item_y = _y;
float mouse_x, mouse_y;
SDL_GetMouseState(&mouse_x, &mouse_y);
for(const auto& app_name : _apps) {
/* Check for hover. */
if(mouse_x >= _x && mouse_x <= _x + _width &&
mouse_y >= item_y && mouse_y <= item_y + item_height) {
ui_renderer->draw_rect(_x, item_y, _width, item_height, hover_color);
}
ui_renderer->render_text(app_name.c_str(), _x+10, item_y+20, text_color);
item_y += item_height;
}
ui_renderer->flush_shapes();
ui_renderer->flush_text();
}
std::string Launcher::handle_event(SDL_Event* event, int screen_height) {
if(event->type == SDL_EVENT_MOUSE_BUTTON_UP) {
int mouse_x = event->button.x;
int mouse_y = event->button.y;
int item_height = 30;
int item_y = _y;
for(const auto& app_name : _apps) {
if(mouse_x >= _x && mouse_x <= _x + _width &&
mouse_y >= item_y && mouse_y <= item_y + item_height) {
return app_name; /* Return name of clicked app. */
}
item_y += item_height;
}
}
return ""; /* Nothing clicked. */
}
void Launcher::set_y(int y) {
_y = y;
}
int Launcher::get_height(void) const {
return _height;
}

23
client/src/ui/launcher.h Normal file
View File

@ -0,0 +1,23 @@
#pragma once
#include <string>
#include <vector>
#include <SDL3/SDL_events.h>
class UIRenderer;
class Launcher {
public:
Launcher(int x, int y, int width);
~Launcher(void);
void render(UIRenderer* ui_renderer);
std::string handle_event(SDL_Event* event, int screen_height);
bool is_point_inside(int x, int y, int screen_height) const;
void set_y(int y);
int get_height(void) const;
private:
int _x, _y, _width, _height;
std::vector<std::string> _apps;
};

View File

@ -0,0 +1,195 @@
#include <string>
#include <vector>
#include "gfx/types.h"
#include "ui/ui_renderer.h"
#include "ui/login_screen.h"
LoginScreen::LoginScreen(int screen_width, int screen_height) :
_screen_width(screen_width),
_screen_height(screen_height) {
_is_new_account = false; /* Default to login. */
/* Position tabs relative to the form. */
const int form_center_x = _screen_width / 2 - 25;
const int tabs_y = (_screen_height / 2 - 100) + 200;
_login_tab_rect = { form_center_x-90, tabs_y, 80, 30 };
_create_account_tab_rect = { form_center_x+10, tabs_y, 150, 30 };
}
LoginScreen::~LoginScreen(void) {}
void LoginScreen::update(float dt) {
/* TODO: */
}
void LoginScreen::render(const RenderContext& context) const {
UIRenderer* ui_renderer = context.ui_renderer;
/* Colours. */
const Color bg_color = { 0.06f, 0.07f, 0.09f };
const Color text_color = { 0.8f, 0.8f, 0.8f };
const Color inactive_box_color = { 0.1f, 0.1f, 0.15f };
const Color active_box_color = { 0.15f, 0.15f, 0.2f };
const Color warning_color = { 0.7f, 0.3f, 0.3f };
const Color active_tab_color = { 0.9f, 0.9f, 0.9f };
const Color inactive_tab_color = { 0.4f, 0.5f, 0.6f };
/* Layout. */
const int box_width = 300;
const int box_height = 30;
const int center_x = (_screen_width - box_width) / 2;
const int start_y = _screen_height / 2 - 100;
const Rect username_rect = { center_x, start_y+40, box_width, box_height };
const Rect password_rect = { center_x, start_y+100, box_width, box_height };
const Rect hostname_rect = { center_x, start_y+160, box_width, box_height };
ui_renderer->begin_shapes();
ui_renderer->begin_text();
/* Draw background. */
ui_renderer->draw_rect(0, 0, _screen_width, _screen_height, bg_color);
/* Draw mode-switch tabs. */
ui_renderer->render_text("[Login]", _login_tab_rect.x, _login_tab_rect.y + 20,
_is_new_account ? inactive_tab_color : active_tab_color);
ui_renderer->render_text("[Create Account]", _create_account_tab_rect.x,
_create_account_tab_rect.y + 20,
_is_new_account ? active_tab_color : inactive_tab_color);
/* Draw title. */
const char* title = _is_new_account ? "Create Account" : "Login";
float title_width = ui_renderer->get_text_renderer()->get_text_width(title, 1.0f);
ui_renderer->render_text(title, (_screen_width-title_width)/2, start_y, text_color);
/* Draw error message if it exists. */
if(!_error_message.empty()) {
float error_width = ui_renderer->get_text_renderer()->get_text_width(_error_message.c_str(), 1.0f);
ui_renderer->render_text(_error_message.c_str(), (_screen_width-error_width)/2,
start_y+20, warning_color);
}
/* Draw input boxes and labels. */
/* Username */
ui_renderer->render_text("Username", username_rect.x, username_rect.y-5, text_color);
ui_renderer->draw_rect(username_rect.x, username_rect.y, username_rect.w, username_rect.h,
_active_field == 0 ? active_box_color : inactive_box_color);
ui_renderer->render_text(_username_input.c_str(), username_rect.x+10, username_rect.y+20, text_color);
if(_active_field == 0 && context.show_cursor) {
float cursor_x = username_rect.x + 10 +
ui_renderer->get_text_renderer()->get_text_width(_username_input.c_str(), 1.0f);
ui_renderer->draw_rect((int)cursor_x, username_rect.y+5, 2, 20, text_color);
}
/* Password. */
ui_renderer->render_text("Password", password_rect.x, password_rect.y-5, text_color);
ui_renderer->draw_rect(password_rect.x, password_rect.y, password_rect.w, password_rect.h,
_active_field == 1 ? active_box_color : inactive_box_color);
ui_renderer->render_text(_password_input.c_str(), password_rect.x+10, password_rect.y+20, text_color);
if(_active_field == 1 && context.show_cursor) {
float cursor_x = password_rect.x + 10 +
ui_renderer->get_text_renderer()->get_text_width(_password_input.c_str(), 1.0f);
ui_renderer->draw_rect((int)cursor_x, password_rect.y+5, 2, 20, text_color);
}
/* Hostname (only for new account). */
if(_is_new_account) {
ui_renderer->render_text("Hostname", hostname_rect.x, hostname_rect.y-5, text_color);
ui_renderer->draw_rect(hostname_rect.x, hostname_rect.y, hostname_rect.w, hostname_rect.h,
_active_field == 2 ? active_box_color : inactive_box_color);
ui_renderer->render_text(_hostname_input.c_str(), hostname_rect.x+10, hostname_rect.y+20, text_color);
if(_active_field == 2 && context.show_cursor) {
float cursor_x = hostname_rect.x + 10 +
ui_renderer->get_text_renderer()->get_text_width(_hostname_input.c_str(), 1.0f);
ui_renderer->draw_rect((int)cursor_x, hostname_rect.y+5, 2, 20, text_color);
}
}
/* Draw the security warning. */
const char* warning_title = "Security Warning";
float warning_title_width = ui_renderer->get_text_renderer()->get_text_width(warning_title, 1.0f);
ui_renderer->render_text(warning_title, (_screen_width-warning_title_width)/2,
_screen_height-120, warning_color);
const std::vector<std::string> warning_lines = {
"Passwords in Bettola are stored in plain text by design.",
"Players, can, and will attempt to obtain your password.",
"DO NOT use a password you have used for any other game or service.",
"Use a unique, throwaway password for this game only!"
};
for(size_t i = 0; i < warning_lines.size(); ++i) {
const std::string& line = warning_lines[i];
float line_width = ui_renderer->get_text_renderer()->get_text_width(line.c_str(), 1.0f);
ui_renderer->render_text(line.c_str(), (_screen_width-line_width)/2,
_screen_height-100+(i*20), warning_color);
}
ui_renderer->flush_shapes();
ui_renderer->flush_text();
}
void LoginScreen::set_error_message(const std::string& msg) {
_error_message = msg;
}
void LoginScreen::handle_event(const SDL_Event* event) {
if(event->type == SDL_EVENT_TEXT_INPUT) {
/* Append character to active input string. */
switch(_active_field) {
case 0: _username_input += event->text.text; break;
case 1: _password_input += event->text.text; break;
case 2: if(_is_new_account) { _hostname_input += event->text.text; } break;
}
} else if(event->type == SDL_EVENT_KEY_DOWN) {
if(event->key.key == SDLK_BACKSPACE) {
/* Handle backspace. */
switch(_active_field) {
case 0: if(!_username_input.empty()) { _username_input.pop_back(); } break;
case 1: if(!_password_input.empty()) { _password_input.pop_back(); } break;
case 2: if(_is_new_account && !_hostname_input.empty()) {
_hostname_input.pop_back();
}
break;
}
} else if(event->key.key == SDLK_TAB) {
/* Tab to switch fields. */
int num_fields = _is_new_account ? 3 : 2;
_active_field = (_active_field+1) % num_fields;
} else if(event->key.key == SDLK_RETURN) {
_login_attempted = true;
}
} else if(event->type == SDL_EVENT_MOUSE_BUTTON_DOWN) {
/* Handle mouse clicks. */
int mouse_x = event->button.x;
int mouse_y = event->button.y;
/* Recalculate rects to check for clicks. */
const int box_width = 300;
const int start_y = _screen_height / 2 - 100;
const int center_x = (_screen_width - box_width) / 2;
const Rect username_rect = { center_x, start_y+40, box_width, 30 };
const Rect password_rect = { center_x, start_y+100, box_width, 30 };
const Rect hostname_rect = { center_x, start_y+160, box_width, 30 };
auto is_inside = [&](const Rect& rect) {
return mouse_x >= rect.x && mouse_x <= rect.x + rect.w &&
mouse_y >= rect.y && mouse_y <= rect.y + rect.h;
};
if(is_inside(_login_tab_rect)) {
_is_new_account = false;
_username_input.clear(); _password_input.clear(); _hostname_input.clear();
} else if(is_inside(_create_account_tab_rect)) {
_is_new_account = true;
_username_input.clear(); _password_input.clear(); _hostname_input.clear();
} else if(is_inside(username_rect)) {
_active_field = 0;
} else if(is_inside(password_rect)) {
_active_field = 1;
} else if(_is_new_account && is_inside(hostname_rect)) {
_active_field = 2;
}
}
}

View File

@ -0,0 +1,43 @@
#pragma once
#include <SDL3/SDL.h>
#include <string>
#include "gfx/types.h"
class LoginScreen {
public:
LoginScreen(int screen_width, int screen_height);
~LoginScreen(void);
void update(float dt);
void render(const RenderContext& context) const;
void handle_event(const SDL_Event* event);
bool is_login_attempted(void) const { return _login_attempted; }
std::string get_username(void) const { return _username_input; }
std::string get_password(void) const { return _password_input; }
std::string get_hostname(void) const { return _hostname_input; }
bool is_new_account_mode(void) const { return _is_new_account; }
void clear_login_attempt(void) { _login_attempted = false;}
void set_error_message(const std::string& msg);
private:
/* UI State. */
std::string _username_input;
std::string _password_input;
std::string _hostname_input;
std::string _error_message;
bool _login_attempted = false;
bool _is_new_account = true;
int _active_field = 0; /* 0: username, 1: password, 2: hostname. */
/* Screen dimensions. */
int _screen_width;
int _screen_height;
/* Clickable tabs for mode switching. */
Rect _create_account_tab_rect;
Rect _login_tab_rect;
};

148
client/src/ui/main_menu.cpp Normal file
View File

@ -0,0 +1,148 @@
#include <SDL3/SDL_events.h>
#include <fstream>
#include <vector>
#include "ui/ui_renderer.h"
#include "main_menu.h"
/* Get a random snippet form loaded word list. */
static const std::string& get_random_snippet(const std::vector<std::string>& snippets) {
/* If empty, return a default to avoid a crash? */
if(snippets.empty()) {
static const std::string empty_str = "ERROR: snippets file not found";
return empty_str;
}
int index = rand() % snippets.size();
return snippets[index];
}
MainMenu::MainMenu(int screen_width, int screen_height) :
_screen_width(screen_width), _screen_height(screen_height),
_next_screen(Screen::MAIN_MENU) {
/* Load snippets form file. */
std::ifstream snippet_file("assets/menu_background_snippets.txt");
if(!snippet_file.is_open()) {
printf("ERROR: Failed to open assets/menu_background_snippets.txt\n");
} else {
std::string line;
while(std::getline(snippet_file, line)) {
if(!line.empty()) {
_snippets.push_back(line);
}
}
}
/* Initialise buttons. */
const int button_width = 200;
const int button_height = 50;
const int center_x = (screen_width/2) - (button_width/2);
const int center_y = (screen_height/2);
_buttons.push_back({
"Single-Player",
{ center_x, center_y + 30, button_width, button_height },
Screen::LOGIN, /* This will trigger the login screen. */
true,
false
});
_buttons.push_back({
"Online",
{ center_x, center_y - 30, button_width, button_height },
Screen::LOGIN,
false,
false
});
/* Initialise animated background. */
srand(time(NULL));
for(int i = 0; i < 100; ++i) {
_background_text.push_back({
get_random_snippet(_snippets),
(float)(rand() % screen_width),
(float)(rand() % screen_height),
(float)(rand() % 50 + 20) / 100.0f /* Random speed. */
});
}
}
MainMenu::~MainMenu(void) {}
void MainMenu::handle_event(SDL_Event* event) {
if(event->type == SDL_EVENT_MOUSE_MOTION) {
int mouse_x = event->motion.x;
int mouse_y = event->motion.y;
for(auto& button : _buttons) {
button.is_hovered = (mouse_x >= button.rect.x
&& mouse_x <= button.rect.x + button.rect.w
&& mouse_y >= button.rect.y
&& mouse_y <= button.rect.y + button.rect.h);
}
} else if(event->type == SDL_EVENT_MOUSE_BUTTON_DOWN) {
for(const auto& button : _buttons) {
if(button.is_hovered) {
_next_screen = button.action;
_clicked_button = &button;
break; /* Once clicked button found, exit. */
}
}
}
}
Screen MainMenu::update(float dt) {
_update_background(dt);
return _next_screen;
}
void MainMenu::render(UIRenderer* ui_renderer) {
/* Pass 1: Background text. */
ui_renderer->begin_text();
_render_background(ui_renderer);
ui_renderer->flush_text();
/* Pass 2: Buttons. */
ui_renderer->begin_shapes();
ui_renderer->begin_text();
/* Button colours. */
const Color button_color = { 0.1f, 0.15f, 0.2f };
const Color button_hover_color = { 0.2f, 0.25f, 0.3f };
const Color text_color = { 0.8f, 0.8f, 0.8f };
for(const auto& button : _buttons) {
/* Draw button background. */
if(button.is_hovered) {
ui_renderer->draw_rect(button.rect.x, button.rect.y, button.rect.w,
button.rect.h, button_hover_color);
} else {
ui_renderer->draw_rect(button.rect.x, button.rect.y, button.rect.w,
button.rect.h, button_color);
}
/* Draw button text centered. */
float text_width = ui_renderer->get_text_renderer()->get_text_width(button.label.c_str(), 1.0f);
float text_x = button.rect.x + (button.rect.w - text_width) / 2.0f;
ui_renderer->render_text(button.label.c_str(), text_x, button.rect.y + 32, text_color);
}
ui_renderer->flush_shapes();
ui_renderer->flush_text();
}
void MainMenu::_update_background(float dt) {
for(auto& line : _background_text) {
line.y -= line.speed * dt * 100.0f;
if(line.y < 0) {
line.y = _screen_height;
line.x = (float)(rand() % _screen_width);
line.text = get_random_snippet(_snippets);
}
}
}
void MainMenu::_render_background(UIRenderer* ui_renderer) {
const Color background_text_color = { 0.0f, 0.35f, 0.15f }; /* Dark green. */
for(const auto& line : _background_text) {
ui_renderer->render_text(line.text.c_str(), line.x, line.y, background_text_color);
}
}

52
client/src/ui/main_menu.h Normal file
View File

@ -0,0 +1,52 @@
#pragma once
#include <vector>
#include <string>
#include "gfx/types.h"
#include "ui/ui_renderer.h"
#include "game_state.h"
union SDL_Event;
struct MenuButton {
std::string label;
Rect rect;
Screen action; /* Change state. */
bool is_single_player;
bool is_hovered = false;
};
class MainMenu {
public:
MainMenu(int screen_width, int screen_height);
~MainMenu(void);
void handle_event(SDL_Event* event);
Screen update(float dt);
void render(UIRenderer* ui_renderer);
const MenuButton* get_clicked_button(void) const { return _clicked_button; }
private:
void _update_background(float dt);
void _render_background(UIRenderer* ui_renderer);
/* For animated background. */
struct ScrollingText {
std::string text;
float x;
float y;
float speed;
};
std::vector<ScrollingText> _background_text;
int _screen_height;
int _screen_width;
/* Buttons. */
std::vector<MenuButton> _buttons;
std::vector<std::string> _snippets;
Screen _next_screen;
const MenuButton* _clicked_button = nullptr;
};

112
client/src/ui/menu_bar.cpp Normal file
View File

@ -0,0 +1,112 @@
#include "menu_bar.h"
#include "gfx/shape_renderer.h"
#include "gfx/txt_renderer.h"
#include "gfx/types.h"
MenuBar::MenuBar(int height)
: _height(height) {}
MenuBar::~MenuBar(void) {}
void MenuBar::add_menu(const std::string& label) {
_menus.push_back({label, {}, false});
}
void MenuBar::add_menu_item(const std::string& menu_label, const std::string& item_label,
std::function<void()> action) {
for(auto& menu : _menus) {
if(menu.label == menu_label) {
menu.items.push_back({item_label, action});
return;
}
}
}
int MenuBar::get_height(void) const {
return _height;
}
void MenuBar::handle_event(SDL_Event* event, int window_x, int window_y) {
if(event->type == SDL_EVENT_MOUSE_BUTTON_DOWN) {
int mouse_x = event->button.x;
int mouse_y = event->button.y;
const int title_bar_height = 30;
int menu_bar_screen_y = window_y + title_bar_height;
int current_menu_x = window_x;
bool click_on_bar = false;
if(mouse_y >= menu_bar_screen_y && mouse_y <= menu_bar_screen_y + _height) {
for(size_t i = 0; i < _menus.size(); ++i) {
int menu_width = 60;
if(mouse_x >= current_menu_x && mouse_x <= current_menu_x + menu_width) {
_open_menu_index = (_open_menu_index == (int)i) ? -1: i;
click_on_bar = true;
break;
}
current_menu_x += menu_width;
}
}
if(click_on_bar) return;
if(_open_menu_index != -1) {
Menu& open_menu = _menus[_open_menu_index];
int dropdown_x = window_x + (_open_menu_index * 60);
int dropdown_y = menu_bar_screen_y + _height;
int item_height = 30;
int dropdown_width = 150;
for(const auto& item: open_menu.items) {
if(mouse_x >= dropdown_x && mouse_x <= dropdown_x + dropdown_width &&
mouse_y >= dropdown_y && mouse_y <= dropdown_y + item_height) {
item.action();
_open_menu_index = -1; /* Close menu. */
return;
}
dropdown_y += item_height;
}
}
/* Close any open menu when clicked outside. */
_open_menu_index = -1;
}
}
void MenuBar::render_bar_bg(UIRenderer* ui_renderer, int x, int y, int width) {
const Color bg_color = { 0.15f, 0.17f, 0.19f };
ui_renderer->draw_rect(x, y, width, _height, bg_color);
}
void MenuBar::render_bar_text(UIRenderer *ui_renderer, int x, int y, int width) {
const Color text_color = { 0.9f, 0.9f, 0.9f };
int menu_x = x;
for(size_t i = 0; i < _menus.size(); ++i) {
int menu_width = 60;
ui_renderer->render_text(_menus[i].label.c_str(), menu_x+10, y+20, text_color);
menu_x += menu_width;
}
}
void MenuBar::render_dropdown(UIRenderer* ui_renderer, int x, int y, int width ) {
if(_open_menu_index == -1) return;
const Color bg_color = { 0.15f, 0.17f, 0.19f };
const Color text_color = { 0.9f, 0.9f, 0.9f };
ui_renderer->begin_shapes();
ui_renderer->begin_text();
int menu_x = x + (_open_menu_index*60);
int item_y = y + _height;
int item_height = 30;
int dropdown_width = 150;
for(const auto& item : _menus[_open_menu_index].items) {
ui_renderer->draw_rect(menu_x, item_y, dropdown_width, item_height, bg_color);
ui_renderer->render_text(item.label.c_str(), menu_x+10, item_y+20, text_color);
item_y += item_height;
}
ui_renderer->flush_shapes();
ui_renderer->flush_text();
}

40
client/src/ui/menu_bar.h Normal file
View File

@ -0,0 +1,40 @@
#pragma once
#include <string>
#include <vector>
#include <functional>
#include <SDL3/SDL_events.h>
#include "ui/ui_renderer.h"
struct MenuItem {
std::string label;
std::function<void()> action;
};
struct Menu {
std::string label;
std::vector<MenuItem> items;
bool is_open = false;
};
class MenuBar {
public:
MenuBar(int height);
~MenuBar(void);
void add_menu(const std::string& label);
void add_menu_item(const std::string& menu_label, const std::string& item_label,
std::function<void()> action);
void handle_event(SDL_Event* event, int window_x, int window_y);
void render_bar_bg(UIRenderer* ui_renderer, int x, int y, int width);
void render_bar_text(UIRenderer* ui_renderer, int x, int y, int width);
void render_dropdown(UIRenderer* ui_renderer, int x, int y, int width);
int get_height(void) const;
private:
int _height;
std::vector<Menu> _menus;
int _open_menu_index = -1;
};

114
client/src/ui/taskbar.cpp Normal file
View File

@ -0,0 +1,114 @@
#include <algorithm>
#include <memory>
#include <chrono>
#include <ctime>
#include <iomanip>
#include <sstream>
#include "taskbar.h"
#include "ui/ui_renderer.h"
#include "ui/ui_window.h"
Taskbar::Taskbar(int screen_width, int screen_height) {
_width = screen_width;
_height = 32;
_y_pos = screen_height - _height; /* Taskbar at bottom because boring? */
_start_button_width = 60;
}
Taskbar::~Taskbar(void) {}
void Taskbar::add_window(UIWindow* window) {
_buttons.push_back({window, window->get_title()});
}
void Taskbar::remove_window(UIWindow* window) {
_buttons.erase(
std::remove_if(_buttons.begin(), _buttons.end(),
[window](const TaskbarButton& btn) {
return btn.window == window;
}),
_buttons.end());
}
void Taskbar::render(UIRenderer* ui_renderer, UIWindow* focused_window) {
const Color taskbar_color = { 0.1f, 0.12f, 0.14f };
const Color button_color = { 0.2f, 0.22f, 0.24f };
const Color button_focused_color = { 0.3f, 0.32f, 0.34f };
const Color button_text_color = { 0.9f, 0.9f, 0.9f };
ui_renderer->begin_shapes();
ui_renderer->begin_text();
ui_renderer->draw_rect(0, _y_pos, _width, _height, taskbar_color);
/* Draw start button. */
ui_renderer->draw_rect(5, _y_pos + 5, _start_button_width - 10,
_height - 10, button_color);
ui_renderer->render_text("[B]", 20, _y_pos + 20, button_text_color);
/* Draw app buttons. */
int button_width = 150;
int padding = 5;
int x_offset = _start_button_width + padding;
for(const auto& button: _buttons) {
bool is_focused = (button.window == focused_window);
ui_renderer->draw_rect(x_offset, _y_pos + padding, button_width,
_height - (padding * 2),
is_focused ? button_focused_color : button_color);
/* TODO: Truncate text when too long. */
ui_renderer->render_text(button.title.c_str(), x_offset + 10,
_y_pos + 20, button_text_color);
x_offset += button_width + padding;
}
/* Draw clock. */
auto now = std::chrono::system_clock::now();
auto in_time_t = std::chrono::system_clock::to_time_t(now);
std::stringstream ss;
ss << std::put_time(std::localtime(&in_time_t), "%H:%M");
std::string time_str = ss.str();
ui_renderer->render_text(time_str.c_str(), _width-50, _y_pos+20, button_text_color);
ui_renderer->flush_shapes();
ui_renderer->flush_text();
}
UIWindow* Taskbar::handle_event(SDL_Event* event, int screen_height) {
if(event->type == SDL_EVENT_MOUSE_BUTTON_UP) {
int mouse_x = event->button.x;
int mouse_y = event->button.y;
int button_width = 150;
int padding = 5;
int x_offset = _start_button_width + padding;
for(const auto& button : _buttons) {
if(mouse_x >= x_offset && mouse_x <= x_offset + button_width &&
mouse_y >= _y_pos && mouse_y <= _y_pos + _height) {
return button.window; /* Return clicked window. */
}
x_offset += button_width + padding;
}
}
return nullptr; /* No window button was clicked. */
}
bool Taskbar::is_start_button_clicked(SDL_Event* event, int screen_height) {
if(event->type == SDL_EVENT_MOUSE_BUTTON_UP) {
int mouse_x = event->button.x;
int mouse_y = event->button.y;
return (mouse_x >= 0 && mouse_x <= _start_button_width && mouse_y >= _y_pos &&
mouse_y <= _y_pos + _height);
}
return false;
}
int Taskbar::get_height(void) const {
return _height;
}
bool Taskbar::is_point_inside(int x, int y) const {
return (y >= _y_pos && y<= _y_pos + _height);
}

35
client/src/ui/taskbar.h Normal file
View File

@ -0,0 +1,35 @@
#pragma once
#include <SDL3/SDL_events.h>
#include <string>
#include <vector>
class UIWindow;
class UIRenderer;
struct TaskbarButton {
UIWindow* window;
std::string title;
};
class Taskbar {
public:
Taskbar(int screen_width, int screen_height);
~Taskbar(void);
void add_window(UIWindow* window);
void remove_window(UIWindow* window);
void render(UIRenderer* ui_renderer, UIWindow* focused_window);
UIWindow* handle_event(SDL_Event* event, int screen_height);
bool is_start_button_clicked(SDL_Event* event, int screen_height);
bool is_point_inside(int x, int y) const;
int get_height(void) const;
private:
std::vector<TaskbarButton> _buttons;
int _width;
int _height;
int _y_pos;
int _start_button_width;
};

250
client/src/ui/text_view.cpp Normal file
View File

@ -0,0 +1,250 @@
#include <SDL3/SDL_events.h>
#include <string>
#include "text_view.h"
#include "gfx/types.h"
#include "ui/editor.h"
#include "ui/text_buffer.h"
#include "ui/ui_renderer.h"
TextView::TextView(TextBuffer* buffer, bool handle_ret, bool show_line_numbers,
bool syntax_highlighting) :
_buffer(buffer),
_scroll_offset(0),
_handle_ret(handle_ret),
_show_line_numbers(show_line_numbers),
_syntax_highlighting(syntax_highlighting) {
_lua_keywords = {
"and", "break", "do", "else", "elseif", "end", "false", "for",
"function", "if", "in", "local", "nil", "not", "or", "repeat",
"return", "then", "true", "until", "while"
};
}
TextView::~TextView(void) = default;
bool TextView::handle_event(SDL_Event* event, int content_height) {
if(!_buffer) return false;
if(event->type == SDL_EVENT_TEXT_INPUT) {
_buffer->insert_char(event->text.text[0]);
_ensure_cursor_visible(content_height);
} else if(event->type == SDL_EVENT_KEY_DOWN) {
switch(event->key.key) {
case SDLK_BACKSPACE:
_buffer->backspace();
_ensure_cursor_visible(content_height);
break;
case SDLK_RETURN:
/*
* For editor, we want a real newline.
* For terminal, we just want to submit. No newline.
* We'll return true and let the parent component do the do'ing.
*/
if(_handle_ret) {
_buffer->newline();
}
_ensure_cursor_visible(content_height);
return true;
break;
case SDLK_LEFT:
_buffer->move_cursor(-1,0);
_ensure_cursor_visible(content_height);
break;
case SDLK_RIGHT:
_buffer->move_cursor(1,0);
_ensure_cursor_visible(content_height);
break;
case SDLK_UP:
_buffer->move_cursor(0,-1);
_ensure_cursor_visible(content_height);
break;
case SDLK_DOWN:
_buffer->move_cursor(0,1);
_ensure_cursor_visible(content_height);
break;
case SDLK_HOME:
_buffer->move_cursor_home();
_ensure_cursor_visible(content_height);
break;
case SDLK_END:
_buffer->move_cursor_end();
break;
default:
break;
}
}
return false;
}
void TextView::scroll(int amount, int content_height) {
_scroll_offset += amount;
if(_scroll_offset < 0) {
_scroll_offset = 0;
}
float line_height = 20.0f;
int visible_lines = static_cast<int>(content_height / line_height)-1;
int max_scroll = _buffer->get_line_count() - visible_lines;
/* Allow one extra scroll step if content is larger than visible area. */
if(_buffer->get_line_count() > visible_lines) max_scroll += 1;
if(max_scroll < 0) {
max_scroll = 0;
}
if(max_scroll < 0) max_scroll = 0;
if(_scroll_offset > max_scroll) {
_scroll_offset = max_scroll;
}
}
std::vector<Token> TextView::_tokenize_line(const std::string& line) {
std::vector<Token> tokens;
std::string current_token_text;
TokenType current_token_type = TokenType::NORMAL;
for(size_t i = 0; i < line.length(); ++i) {
char c = line[i];
/* Check for comments. */
if(i + 1 < line.length() && c == '-' && line[i+1] == '-') {
if(!current_token_text.empty()) {
tokens.push_back({current_token_type, current_token_text});
}
tokens.push_back({TokenType::COMMENT, line.substr(i)});
return tokens;
}
/* Check for strings. */
if(c == '"') {
if(!current_token_text.empty()) {
tokens.push_back({current_token_type, current_token_text});
}
size_t end_quote = line.find('"', i+1);
if(end_quote == std::string::npos) {
tokens.push_back({TokenType::STRING, line.substr(i)});
return tokens;
}
tokens.push_back({TokenType::STRING, line.substr(i, end_quote - i+1)});
i = end_quote;
current_token_text = "";
continue;
}
/* Check for numbers. */
if(isdigit(c) && (current_token_text.empty() || current_token_type == TokenType::NUMBER)) {
current_token_type = TokenType::NUMBER;
current_token_text += c;
} else if(isalnum(c) || c == '_') { /* Keywords and identifiers. */
if(current_token_type != TokenType::NORMAL) {
tokens.push_back({current_token_type, current_token_text});
current_token_text = "";
}
current_token_type = TokenType::NORMAL;
current_token_text += c;
} else { /* Delimiters. */
if(!current_token_text.empty()) {
if(_lua_keywords.count(current_token_text)) {
tokens.push_back({TokenType::KEYWORD, current_token_text});
} else {
tokens.push_back({current_token_type, current_token_text});
}
current_token_text = "";
}
tokens.push_back({TokenType::NORMAL, std::string(1, c)});
current_token_type = TokenType::NORMAL;
}
}
if(!current_token_text.empty()) {
if(_lua_keywords.count(current_token_text)) {
tokens.push_back({TokenType::KEYWORD, current_token_text});
} else {
tokens.push_back({current_token_type, current_token_text});
}
}
return tokens;
}
void TextView::render_text_content(UIRenderer* ui_renderer, const SyntaxTheme& theme,
int x, int y, int width, int height) {
if(!_buffer) return;
const float line_height = 20.0f; /* TODO: Get font metrics? */
const float padding = 5.0f;
const float gutter_width = _show_line_numbers ? 40.0f : 0.0f;
float current_y = y;
for(size_t i = _scroll_offset; i < _buffer->get_line_count(); ++i) {
/*
* Culling: If the top of the current line is already below the bottom of the
* view, we can stop rendering completely.
*/
if(current_y >= y + height) {
break;
}
if(_show_line_numbers) {
/* Render line number. */
std::string line_num_str = std::to_string(i+1);
float line_num_text_width =
ui_renderer->get_text_renderer()->get_text_width(line_num_str.c_str(), 1.0f);
ui_renderer->render_text(line_num_str.c_str(), x+padding+(gutter_width-line_num_text_width-10),
current_y+18, theme.line_num);
}
float current_x = x+padding + gutter_width;
if(_syntax_highlighting) {
std::vector<Token> tokens = _tokenize_line(_buffer->get_line(i));
for(const auto& token : tokens) {
Color color = theme.normal;
switch(token.type) {
case TokenType::KEYWORD: color = theme.keyword; break;
case TokenType::STRING: color = theme.string; break;
case TokenType::NUMBER: color = theme.number; break;
case TokenType::COMMENT: color = theme.comment; break;
default: break;
}
ui_renderer->render_text(token.text.c_str(), current_x, current_y+18, color);
current_x += ui_renderer->get_text_renderer()->get_text_width(token.text.c_str(), 1.0f);
}
} else {
ui_renderer->render_text(_buffer->get_line(i).c_str(), current_x, current_y+18, theme.normal);
}
current_y += line_height;
}
}
void TextView::render_cursor(UIRenderer* ui_renderer, const SyntaxTheme& theme,
int x, int y, int width, int height) {
const float line_height = 20.0f; /* TODO: Get font metrics? */
const float padding = 5.0f;
const float gutter_width = _show_line_numbers ? 40.0f : 0.0f;
Point cursor_pos = _buffer->get_cursor_pos();
std::string line_before_cursor = _buffer->get_line(cursor_pos.row).substr(0, cursor_pos.col);
float text_width =
ui_renderer->get_text_renderer()->get_text_width(line_before_cursor.c_str(), 1.0f);
float cursor_x = x + padding + gutter_width + text_width;
float cursor_y = y + (cursor_pos.row - _scroll_offset) * line_height;
if(cursor_y >= y && cursor_y < y + height) {
ui_renderer->draw_rect(cursor_x, cursor_y, 2, line_height, theme.normal);
}
}
void TextView::_ensure_cursor_visible(int content_height) {
Point cursor_pos = _buffer->get_cursor_pos();
float line_height = 20.0f;
int visible_lines = static_cast<int>(content_height / line_height)-1;
/* If cursor above current scroll view, scroll up. */
if(cursor_pos.row < _scroll_offset) {
_scroll_offset = cursor_pos.row;
} else if(cursor_pos.row >= _scroll_offset + visible_lines) {
/* Below, scroll down. */
_scroll_offset = cursor_pos.row - visible_lines+1;
}
/* Re-clamp scroll offset to ensure it's within valid bounds. */
scroll(0, content_height);
}

49
client/src/ui/text_view.h Normal file
View File

@ -0,0 +1,49 @@
#pragma once
#include <SDL3/SDL_events.h>
#include <string>
#include <vector>
#include <unordered_set>
#include "ui/text_buffer.h"
#include "ui/ui_renderer.h"
#include "ui/editor.h" /* For SyntaxTheme */
class TextRenderer;
enum class TokenType {
NORMAL,
KEYWORD,
STRING,
NUMBER,
COMMENT
};
struct Token {
TokenType type;
std::string text;
};
class TextView {
public:
TextView(TextBuffer* buffer, bool handle_ret, bool show_line_numbers, bool syntax_highlighting);
~TextView(void);
bool handle_event(SDL_Event* event, int content_height);
void scroll(int amount, int content_height);
void render_text_content(UIRenderer* ui_renderer, const SyntaxTheme& theme,
int x, int y, int width, int height);
void render_cursor(UIRenderer* ui_renderer, const SyntaxTheme& theme,
int x, int y, int width, int height);
private:
void _ensure_cursor_visible(int content_height);
std::vector<Token> _tokenize_line(const std::string& line);
TextBuffer* _buffer;
int _scroll_offset;
bool _handle_ret;
bool _show_line_numbers;
bool _syntax_highlighting;
std::unordered_set<std::string> _lua_keywords;
};

View File

@ -0,0 +1,64 @@
#include <GL/glew.h>
#include "ui_renderer.h"
#include "gfx/shape_renderer.h"
#include "gfx/txt_renderer.h"
UIRenderer::UIRenderer(ShapeRenderer* shape_renderer, TextRenderer* txt_renderer, int screen_height)
: _shape_renderer(shape_renderer), _txt_renderer(txt_renderer), _screen_height(screen_height) {}
void UIRenderer::draw_rect(int x, int y, int width, int height, const Color& color) {
if(!_shape_renderer) return;
/* Convert top-left screen coord to bottom-left GL coord. */
int y_gl = _screen_height - y - height;
_shape_renderer->draw_rect(x, y_gl, width, height, color);
}
void UIRenderer::draw_triangle(int x1, int y1, int x2, int y2, int x3, int y3, const Color& color) {
if(!_shape_renderer) return;
/* Convert top-left screen coord to bottom-left GL coord. */
int y1_gl = _screen_height - y1;
int y2_gl = _screen_height - y2;
int y3_gl = _screen_height - y3;
_shape_renderer->draw_triangle(x1, y1_gl, x2, y2_gl, x3, y3_gl, color);
}
void UIRenderer::render_text(const char* text, int x, int y, const Color& color) {
if(!_txt_renderer) return;
/* Convert the screen-space baseline y-coord to GL-space baseline y-coord. */
int y_gl = _screen_height - y;
_txt_renderer->render_text(text, x, y_gl, color);
}
void UIRenderer::begin_text(void) {
if(!_txt_renderer) return;
_txt_renderer->begin();
}
void UIRenderer::flush_text(void) {
if(!_txt_renderer) return;
_txt_renderer->flush();
}
void UIRenderer::begin_shapes(void) {
if(!_shape_renderer) return;
_shape_renderer->begin();
}
void UIRenderer::flush_shapes(void) {
if(!_shape_renderer) return;
_shape_renderer->flush();
}
TextRenderer* UIRenderer::get_text_renderer(void) {
return _txt_renderer;
}
void UIRenderer::enable_scissor(int x, int y, int width, int height) {
glEnable(GL_SCISSOR_TEST);
glScissor(x, y, width, height);
}
void UIRenderer::disable_scissor(void) {
glDisable(GL_SCISSOR_TEST);
}

View File

@ -0,0 +1,36 @@
#pragma once
#include "gfx/shape_renderer.h"
#include "gfx/txt_renderer.h"
#include "gfx/types.h"
/*
* I'm so damn sick of working between two rendering systems!
* Here! Have a wrapper around the low-level renderers to provide a f.cking
* consistant top-left origin for all UI components.
*/
class UIRenderer {
public:
UIRenderer(ShapeRenderer* shape_renderer, TextRenderer* txt_renderer, int screen_height);
void draw_rect(int x, int y, int width, int height, const Color& color);
void draw_triangle(int x1, int y1, int x2, int y2, int x3, int y3, const Color& color);
void render_text(const char* text, int x, int y, const Color& color);
void begin_text(void);
void flush_text(void);
void begin_shapes(void);
void flush_shapes(void);
/* Expose underlying text renderer for things like width calculation. */
TextRenderer* get_text_renderer(void);
void enable_scissor(int x, int y, int width, int height);
void disable_scissor(void);
private:
ShapeRenderer* _shape_renderer;
TextRenderer* _txt_renderer;
int _screen_height;
};

224
client/src/ui/ui_window.cpp Normal file
View File

@ -0,0 +1,224 @@
#include "ui_window.h"
#include <SDL3/SDL_events.h>
#include <SDL3/SDL_hidapi.h>
#include <memory>
#include "ui/i_window_content.h"
#include "ui_renderer.h"
UIWindow::UIWindow(const char* title, int x, int y, int width, int height) :
_title(title),
_x(x),
_y(y),
_width(width),
_height(height),
_content(nullptr),
_is_dragging(false),
_is_hovered(false),
_is_focused(false),
_should_close(false),
_state(WindowState::NORMAL),
_is_resizing(false),
_resize_margin(10) {
/* Init title bar buttons. */
_title_bar_buttons.push_back({WindowButtonAction::CLOSE, {0.8f, 0.2f, 0.2f}});
_title_bar_buttons.push_back({WindowButtonAction::MAXIMIZE, {0.2f, 0.8f, 0.2f}});
_title_bar_buttons.push_back({WindowButtonAction::MINIMIZE, {0.8f, 0.8f, 0.2f}});
}
UIWindow::~UIWindow(void) {}
void UIWindow::minimize(void) {
_state = WindowState::MINIMIZED;
}
void UIWindow::restore(void) {
_state = WindowState::NORMAL;
}
bool UIWindow::is_minimized(void) const {
return _state == WindowState::MINIMIZED;
}
const std::string& UIWindow::get_title(void) const {
return _title;
}
Rect UIWindow::get_rect(void) const {
return { _x, _y, _width, _height };
}
bool UIWindow::is_mouse_over_resize_handle(int mouse_x, int mouse_y) const {
return (mouse_x >= _x + _width - _resize_margin && mouse_x <= _x + _width &&
mouse_y >= _y + _height - _resize_margin &&
mouse_y <= _y + _height);
}
bool UIWindow::should_close(void) const {
return _should_close;
}
void UIWindow::update(void) {
if(_content) {
if(_content->should_close()) { _should_close = true; }
}
}
void UIWindow::set_content(std::unique_ptr<IWindowContent> content) {
_content = std::move(content);
}
void UIWindow::set_focused(bool focused) {
_is_focused = focused;
}
IWindowContent* UIWindow::get_content(void) {
return _content.get();
}
bool UIWindow::is_point_inside(int x, int y) {
return (x >= _x && x <= _x + _width &&
y >= _y && y <= _y + _height);
}
void UIWindow::render(const RenderContext& context) {
int title_bar_height = 30;
context.ui_renderer->begin_shapes();
context.ui_renderer->begin_text();
/* Define colours. */
const Color frame_color = { 0.2f, 0.2f, 0.25f };
const Color title_bar_color = { 0.15f, 0.15f, 0.2f };
const Color focused_title_bar_color = { 0.3f, 0.3f, 0.4f };
const Color title_text_color = { 0.9f, 0.9f, 0.9f };
const Color resize_handle_color = { 0.9f, 0.9f, 0.9f };
/* Draw main window frame/background. */
context.ui_renderer->draw_rect(_x, _y, _width, _height, frame_color);
/* Draw title bar. */
if(_is_focused) {
context.ui_renderer->draw_rect(_x, _y, _width, title_bar_height, focused_title_bar_color);
} else {
context.ui_renderer->draw_rect(_x, _y, _width, title_bar_height, title_bar_color);
}
/* Draw title text. */
context.ui_renderer->render_text(_title.c_str(), _x+5, _y+20, title_text_color);
/* Draw title bar buttons. */
int button_size = 20;
int button_margin = 5;
int x_offset = _x + _width - button_size - button_margin;
for(const auto& button : _title_bar_buttons) {
context.ui_renderer->draw_rect(x_offset, _y+button_margin, button_size,
button_size, button.color);
x_offset -= (button_size + button_margin);
}
/* Draw Resize handle. */
int corner_x = _x + _width;
int corner_y = _y + _height;
context.ui_renderer->draw_triangle(corner_x, corner_y - 10, corner_x - 10,
corner_y, corner_x, corner_y, resize_handle_color);
/* Flush the shapes and text for the window frame and title bar. */
context.ui_renderer->flush_shapes();
context.ui_renderer->flush_text();
if(_content) {
int content_screen_y = _y + title_bar_height;
int content_height = _height - title_bar_height;
int content_width = _width;
/* Got to pass GL y-coord for scissor box to work correctly. */
int content_gl_y = context.screen_height - content_screen_y - content_height;
_content->render(context, _x, content_screen_y, content_gl_y, content_width, content_height);
}
}
void UIWindow::handle_event(SDL_Event* event, int screen_width,
int screen_height, int taskbar_height) {
int title_bar_height = 30;
if(event->type == SDL_EVENT_MOUSE_BUTTON_DOWN) {
int mouse_x = event->button.x;
int mouse_y = event->button.y;
/* Check for title bar button clicks. */
int button_size = 20;
int button_margin = 5;
int x_offset = _x + _width - button_size - button_margin;
for(const auto& button : _title_bar_buttons) {
if(mouse_x >= x_offset && mouse_x <= x_offset + button_size &&
mouse_y >= _y + button_margin && mouse_y <= _y + button_margin + button_size) {
switch(button.action) {
case WindowButtonAction::CLOSE:
_should_close = true;
break;
case WindowButtonAction::MAXIMIZE:
if(_state == WindowState::MAXIMIZED) {
_x = _pre_maximize_rect.x;
_y = _pre_maximize_rect.y;
_width = _pre_maximize_rect.w;
_height = _pre_maximize_rect.h;
_state = WindowState::NORMAL;
} else {
_pre_maximize_rect = { _x, _y, _width, _height };
_x = 0; _y = 0;
_width = screen_width; _height = screen_height - taskbar_height;
_state = WindowState::MAXIMIZED;
}
break;
case WindowButtonAction::MINIMIZE:
minimize();
break;
}
return; /* Button clicked, no need to process further. */
}
x_offset -= (button_size + button_margin);
}
/* Check for resize handle click (bottom-right corner). */
if(is_mouse_over_resize_handle(mouse_x, mouse_y) &&
_state != WindowState::MAXIMIZED) {
_is_resizing = true;
} else if(mouse_x >= _x && mouse_x <= _x + _width &&
mouse_y >= _y && mouse_y <= _y + title_bar_height &&
_state != WindowState::MAXIMIZED) {
/* Is click within title bar? */
_is_dragging = true;
_drag_offset_x = mouse_x - _x;
_drag_offset_y = mouse_y - _y;
}
} else if(event->type == SDL_EVENT_MOUSE_BUTTON_UP) {
_is_dragging = false;
_is_resizing = false;
} else if(event->type == SDL_EVENT_MOUSE_MOTION) {
if(_is_dragging) {
_x = event->motion.x - _drag_offset_x;
_y = event->motion.y - _drag_offset_y;
} else if(_is_resizing) {
int new_width = event->motion.x - _x;
int new_height = event->motion.y - _y;
_width = (new_width > 100) ? new_width : 100; /* Min width. */
_height = (new_height > 80) ? new_height : 80; /* Min height. */
}
/* Check if mouse hovered over window. */
_is_hovered = is_point_inside(event->motion.x, event->motion.y);
} else if(event->type == SDL_EVENT_MOUSE_WHEEL) {
/* Only scroll window if mouse is hovering. */
if(_is_hovered && _content) {
/* SDL's wheel motion is negative scroll up on the y. */
int content_height_gl = _height - title_bar_height;
_content->scroll(-event->wheel.y, content_height_gl);
}
}
if(_content) {
int y_gl = screen_height - _y - _height;
int content_height = _height - title_bar_height;
int content_width = _width;
_content->handle_input(event, _x, _y, y_gl, content_width, content_height);
}
}

71
client/src/ui/ui_window.h Normal file
View File

@ -0,0 +1,71 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include "gfx/types.h"
#include "i_window_content.h"
enum class WindowState {
NORMAL,
MINIMIZED,
MAXIMIZED
};
enum class WindowButtonAction {
CLOSE,
MAXIMIZE,
MINIMIZE
};
struct TitleBarButton {
WindowButtonAction action;
Color color;
};
class UIWindow {
public:
UIWindow(const char* title, int x, int y, int width, int height);
~UIWindow(void);
void update(void);
void render(const RenderContext& context);
void handle_event(SDL_Event* event, int screen_width, int screen_height,
int taskbar_height);
void minimize(void);
void restore(void);
void close(void) { _should_close = true; }
bool is_minimized(void) const;
bool should_close(void) const;
void set_focused(bool focused);
bool is_point_inside(int x, int y);
void set_content(std::unique_ptr<IWindowContent> content);
IWindowContent* get_content(void);
bool is_mouse_over_resize_handle(int mouse_x, int mouse_y) const;
const std::string& get_title(void) const;
Rect get_rect(void) const;
void set_session_id(uint32_t id) { _session_id = id; }
uint32_t get_session_id(void) const { return _session_id; }
private:
friend class Taskbar; /* Allow taskbar to access private members. */
int _x, _y, _width, _height;
Rect _pre_maximize_rect;
std::string _title;
std::unique_ptr<IWindowContent> _content;
bool _is_focused; /* Managed by desktop. */
bool _is_hovered; /* Send scroll events even if not focused. */
bool _should_close;
std::vector<TitleBarButton> _title_bar_buttons;
WindowState _state;
bool _is_dragging;
int _drag_offset_x, _drag_offset_y;
bool _is_resizing;
int _resize_margin;
uint32_t _session_id;
};

View File

@ -0,0 +1,17 @@
#pragma once
#include <string>
enum class ActionType {
NONE,
WRITE_FILE,
READ_FILE,
BUILD_FILE,
CLOSE_WINDOW
};
struct WindowAction {
ActionType type = ActionType::NONE;
std::string payload1; /* i.e., filename. */
std::string payload2; /* i.e., content. */
};

16
common/CMakeLists.txt Normal file
View File

@ -0,0 +1,16 @@
find_package(Lua 5.4 REQUIRED)
file(GLOB_RECURSE BETTOLA_SOURCES "src/*.cpp")
add_library(bettola
${BETTOLA_SOURCES}
)
target_link_libraries(bettola PUBLIC ${LUA_LIBRARIES} sol2 sqlite)
target_include_directories(bettola PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src
${LUA_INCLUDE_DIR}
${asio_SOURCE_DIR}/asio/include
${sqlite_modern_cpp_SOURCE_DIR}/hdr
)

6
common/src/bettola.cpp Normal file
View File

@ -0,0 +1,6 @@
#include <cstdio>
#include "bettola.h"
void bettola_function(void) {
printf("Hello from libbettola!\n");
}

3
common/src/bettola.h Normal file
View File

@ -0,0 +1,3 @@
#pragma once
void bettola_function(void);

View File

@ -0,0 +1,97 @@
#include "database_manager.h"
#include <memory>
#include "vfs.h"
DatabaseManager::DatabaseManager(const std::string& db_path) :
_db(db_path) {
_player_repository = std::make_unique<PlayerRepository>(_db);
_machine_repository = std::make_unique<MachineRepository>(_db);
_service_repository = std::make_unique<ServiceRepository>(_db);
_vfs_repository = std::make_unique<VFSRepository>(_db);
_db << "CREATE TABLE IF NOT EXISTS players("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"username TEXT NOT NULL UNIQUE,"
"password TEXT NOT NULL,"
"hostname TEXT NOT NULL,"
"home_machine_id INTEGER"
");";
_db << "CREATE TABLE IF NOT EXISTS machines ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"owner_id INTEGER,"
"hostname TEXT NOT NULL,"
"ip_address TEXT NOT NULL UNIQUE"
");";
_db << "CREATE TABLE IF NOT EXISTS vfs_nodes ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"machine_id INTEGER NOT NULL,"
"parent_id INTEGER,"
"name TEXT NOT NULL,"
"type INTEGER NOT NULL,"
"content TEXT,"
"owner_id INTEGER NOT NULL,"
"group_id INTEGER NOT NULL,"
"permissions INTEGER NOT NULL"
");";
_db << "CREATE TABLE IF NOT EXISTS services ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"machine_id INTEGER NOT NULL,"
"port INTEGER NOT NULL,"
"name TEXT NOT NULL"
");";
}
DatabaseManager::~DatabaseManager(void) {
/* db is auto closed when _db goes out of scope. */
}
bool DatabaseManager::create_player(const std::string& username, const std::string& password,
const std::string& hostname, vfs_node* vfs_template) {
long long player_id = 0;
long long machine_id = 0;
try {
_db << "BEGIN;";
player_id = _player_repository->create(username, password, hostname);
/* Create the home machine. */
/* TODO: Implement real IP allication. */
std::string ip_address = "192.168.1." + std::to_string(player_id);
machine_id = _machine_repository->create(player_id, hostname, ip_address);
_player_repository->set_home_machine_id(player_id, machine_id);
/* Create the root dir for the new machine's VFS. */
long long root_id = _vfs_repository->create_node(machine_id, nullptr, "/", DIR_NODE,
"", player_id, player_id, 0755);
/* Create default subdirs. */
_vfs_repository->create_node(machine_id, &root_id, "home", DIR_NODE,
"", player_id, player_id, 0755);
_vfs_repository->create_node(machine_id, &root_id, "etc", DIR_NODE,
"", player_id, player_id, 0755);
/* Create /bin and get it's ID */
long long bin_id = _vfs_repository->create_node(machine_id, &root_id, "bin", DIR_NODE,
"", player_id, player_id, 0755);
/* Copy scripts from template into new machine's /bin */
vfs_node* template_bin = vfs_template->children["bin"];
for(auto const& [name, node] : template_bin->children) {
_vfs_repository->create_node(machine_id, &bin_id, name, node->type, node->content,
player_id, player_id, 0755);
}
/* Add default SSH service. */
_service_repository->create(machine_id, 22, "SSH");
_db << "COMMIT";
} catch(const std::exception& e) {
_db << "ROLLBACK;"; /* Ensure atomicity. */
return false;
}
return true;
}

View File

@ -0,0 +1,34 @@
#pragma once
#include <string>
#include <memory>
#include "service_repository.h"
#include "player_repository.h"
#include "machine_repository.h"
#include "vfs_repository.h"
#include "sqlite_modern_cpp.h"
#include "vfs.h"
class DatabaseManager {
public:
DatabaseManager(const std::string& db_path);
~DatabaseManager(void);
/* Return true on success, false if user already exists. */
bool create_player(const std::string& username, const std::string& password,
const std::string& hostname, vfs_node* vfs_template);
PlayerRepository& players(void) { return *_player_repository; }
MachineRepository& machines(void) { return *_machine_repository; }
ServiceRepository& services(void) { return *_service_repository; }
VFSRepository& vfs(void) { return *_vfs_repository; }
sqlite::database _db;
private:
std::unique_ptr<PlayerRepository> _player_repository;
std::unique_ptr<MachineRepository> _machine_repository;
std::unique_ptr<ServiceRepository> _service_repository;
std::unique_ptr<VFSRepository> _vfs_repository;
};

3
common/src/db/db.h Normal file
View File

@ -0,0 +1,3 @@
#pragma once
#include <sqlite_modern_cpp.h>

View File

@ -0,0 +1,49 @@
#include "machine_repository.h"
#include "sqlite_modern_cpp.h"
MachineRepository::MachineRepository(sqlite::database& db) : _db(db) {}
long long MachineRepository::create(std::optional<long long> owner_id,
const std::string& hostname,
const std::string& ip_address) {
if(owner_id.has_value()) {
_db << "INSERT INTO machines (owner_id, hostname, ip_address) VALUES(?, ?, ?);"
<< owner_id.value() << hostname << ip_address;
} else {
_db << "INSERT INTO machines (owner_id, hostname, ip_address) VALUES (NULL, ?, ?);"
<< hostname << ip_address;
}
return _db.last_insert_rowid();
}
int MachineRepository::get_npc_count(void) {
int count = 0;
_db << "SELECT count(*) FROM machines WHERE owner_id IS NULL;" >> count;
return count;
}
std::vector<MachineData> MachineRepository::get_all_npcs(void) {
std::vector<MachineData> machines;
_db << "SELECT id, ip_address FROM machines WHERE owner_id IS NULL;"
>> [&](long long id, std::string ip_address) {
machines.push_back({id, ip_address});
};
return machines;
}
std::vector<MachineData> MachineRepository::get_all(void) {
std::vector<MachineData> machines;
_db << "SELECT id, ip_address FROM machines;"
>> [&](long long id, std::string ip_address) {
machines.push_back({id, ip_address});
};
return machines;
}
std::string MachineRepository::get_hostname(long long machine_id) {
std::string hostname;
_db << "SELECT hostname FROM machines WHERE id = ?;"
<< machine_id
>> hostname;
return hostname;
}

View File

@ -0,0 +1,28 @@
#pragma once
#include <string>
#include <vector>
#include <optional>
#include "sqlite_modern_cpp.h"
/* Struct to hold machine data. */
struct MachineData {
long long id;
std::string ip_address;
};
class MachineRepository {
public:
MachineRepository(sqlite::database& db);
long long create(std::optional<long long> owner_id, const std::string& hostname,
const std::string& ip_address);
int get_npc_count(void);
std::vector<MachineData> get_all_npcs(void);
std::vector<MachineData> get_all(void);
std::string get_hostname(long long machine_id);
private:
sqlite::database& _db;
};

View File

@ -0,0 +1,34 @@
#include "player_repository.h"
PlayerRepository::PlayerRepository(sqlite::database& db) : _db(db) {}
long long PlayerRepository::create(const std::string& username, const std::string& password,
const std::string& hostname) {
_db << "INSERT INTO players (username, password, hostname) VALUES (?, ?, ?);"
<< username
<< password
<< hostname;
return _db.last_insert_rowid();
}
bool PlayerRepository::authenticate(const std::string& username, const std::string& password) {
bool authed = false;
_db << "SELECT id FROM players WHERE username = ? AND password = ?;"
<< username
<< password
>> [&](long long id) {authed = true;};
return authed;
}
long long PlayerRepository::get_home_machine_id(const std::string& username) {
long long machine_id = -1;
_db << "SELECT home_machine_id FROM players WHERE username = ?;"
<< username
>> machine_id;
return machine_id;
}
void PlayerRepository::set_home_machine_id(long long player_id, long long machine_id) {
_db << "UPDATE players SET home_machine_id = ? WHERE id = ?;"
<< machine_id << player_id;
}

View File

@ -0,0 +1,19 @@
#pragma once
#include <string>
#include "sqlite_modern_cpp.h"
class PlayerRepository {
public:
PlayerRepository(sqlite::database& db);
long long create(const std::string& username, const std::string& password,
const std::string& hostname);
bool authenticate(const std::string& username, const std::string& password);
long long get_home_machine_id(const std::string& username);
void set_home_machine_id(long long player_id, long long machine_id);
private:
sqlite::database& _db;
};

View File

@ -0,0 +1,19 @@
#include "service_repository.h"
#include "sqlite_modern_cpp.h"
ServiceRepository::ServiceRepository(sqlite::database& db) : _db(db) {}
void ServiceRepository::create(long long machine_id, int port, const std::string& name) {
_db << "INSERT INTO services (machine_id, port, name) VALUES (?, ?, ?);"
<< machine_id << port << name;
}
std::map<int, std::string> ServiceRepository::get_for_machine(long long machine_id) {
std::map<int, std::string> services;
_db << "SELECT port, name FROM services WHERE machine_id = ?;"
<< machine_id
>> [&](int port, std::string name) {
services[port] = name;
};
return services;
}

View File

@ -0,0 +1,17 @@
#pragma once
#include <string>
#include <map>
#include "sqlite_modern_cpp.h"
class ServiceRepository {
public:
ServiceRepository(sqlite::database& db);
void create(long long machine_id, int port, const std::string& name);
std::map<int, std::string> get_for_machine(long long machine_id);
private:
sqlite::database& _db;
};

View File

@ -0,0 +1,46 @@
#include "vfs_repository.h"
VFSRepository::VFSRepository(sqlite::database& db) : _db(db) {}
long long VFSRepository::create_node(long long machine_id, long long* parent_id,
const std::string& name, vfs_node_type type,
const std::string& content,
uint32_t owner_id, uint32_t group_id,
uint16_t permissions) {
if(parent_id) {
_db << "INSERT INTO vfs_nodes (machine_id, parent_id, name, type, content, "
"owner_id, group_id, permissions) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?);"
<< machine_id << *parent_id << name << type << content << owner_id
<< group_id << permissions;
} else {
_db << "INSERT INTO vfs_nodes (machine_id, parent_id, name, type, content, "
"owner_id, group_id, permissions) "
"VALUES (?, NULL, ?, ?, ?, ?, ?, ?);"
<< machine_id << name << type << content
<< owner_id << group_id << permissions;
}
return _db.last_insert_rowid();
}
std::vector<vfs_node*> VFSRepository::get_nodes_for_machine(long long machine_id) {
std::vector<vfs_node*> nodes;
_db << "SELECT id, parent_id, name, type, content, owner_id, group_id, permissions "
"FROM vfs_nodes WHERE machine_id = ?;"
<< machine_id
>> [&](long long id, long long parent_id, std::string name, int type,
std::string content, uint32_t owner_id, uint32_t group_id, uint16_t permissions) {
vfs_node* node = new vfs_node();
node->id = id;
node->parent_id = parent_id;
node->name = name;
node->type = (vfs_node_type) type;
node->content = content;
node->owner_id = owner_id;
node->group_id = group_id;
node->permissions = permissions;
nodes.push_back(node);
};
return nodes;
}

View File

@ -0,0 +1,24 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "vfs.h"
#include "sqlite_modern_cpp.h"
class VFSRepository {
public:
VFSRepository(sqlite::database& db);
long long create_node(long long machine_id, long long* parent_id,
const std::string& name, vfs_node_type type,
const std::string& content = "",
uint32_t owner_id = 0, uint32_t group_id = 0,
uint16_t permissions = 0755);
std::vector<vfs_node*> get_nodes_for_machine(long long machine_id);
private:
sqlite::database& _db;
};

View File

@ -0,0 +1,11 @@
#pragma once
#include <string>
class Machine;
class INetworkBridge {
public:
virtual Machine* get_machine_by_ip(const std::string& ip) = 0;
virtual void release_machine(long long machine_id) = 0;
};

306
common/src/lua_api.cpp Normal file
View File

@ -0,0 +1,306 @@
#include <sol/call.hpp>
#include <sol/types.hpp>
#include <sstream>
#include "i_network_bridge.h"
#include "lua_api.h"
#include "session.h"
#include "machine.h"
#include "vfs.h"
#include "util.h"
namespace api {
vfs_node* get_current_dir(Session& context) {
return context.get_current_dir();
}
std::string rm(Session& context, const std::string& filename) {
vfs_node* current_dir = context.get_current_dir();
auto it = current_dir->children.find(filename);
if(it == current_dir->children.end()) {
return "rm: cannot remove '" + filename + "': No such file or directory.";
}
if(it->second->type == DIR_NODE) {
return "rm: cannot remove '" + filename + "': Is a directory.";
}
delete it->second; /* Free the memory for the node. */
current_dir->children.erase(it); /* Remove from map. */
return "";
}
std::string write_file(Session& context, const std::string& path,
const std::string& content) {
vfs_node* parent_dir = nullptr;
std::string filename;
if(path[0] == '/') {
Machine* session_machine = context.get_session_machine();
vfs_node* root = session_machine->vfs_root;
size_t last_slash = path.find_last_of('/');
if(last_slash == 0) {
/* File in root. */
parent_dir = root;
filename = path.substr(1);
} else {
std::string parent_path = path.substr(0, last_slash);
parent_dir = find_node_by_path(root, parent_path);
filename = path.substr(last_slash+1);
}
} else {
/* Relative path. */
parent_dir = context.get_current_dir();
filename = path;
}
if(!parent_dir) {
return "write: cannot create file '" + path + "': No such file or directory";
}
if(parent_dir->type != DIR_NODE) {
return "write: cannot create file in '" + get_full_path(parent_dir) + "': Not a directory";
}
auto it = parent_dir->children.find(filename);
if(it != parent_dir->children.end()) {
if(it->second->type == DIR_NODE) {
return "write: " + path + ": Is a directory";
}
it->second->content = content;
} else {
/* File does not exist, create it. */
vfs_node* new_file = new_node(filename, FILE_NODE, parent_dir);
new_file->content = content;
parent_dir->children[filename] = new_file;
}
return "";
}
std::string create_executable(Session& context, const std::string& path,
const std::string& content) {
vfs_node* parent_dir = nullptr;
std::string filename;
if(path[0] == '/') {
Machine* session_machine = context.get_session_machine();
vfs_node* root = session_machine->vfs_root;
size_t last_slash = path.find_last_of('/');
if(last_slash == 0) {
parent_dir = root;
filename = path.substr(1);
} else {
std::string parent_path = path.substr(0, last_slash);
parent_dir = find_node_by_path(root, parent_path);
filename = path.substr(last_slash+1);
}
} else {
parent_dir = context.get_current_dir();
filename = path;
}
if(!parent_dir) {
return "create_executable: cannot create file '" + path + "': No such file or directory";
}
if(parent_dir->type != DIR_NODE) {
return "create_executable: cannot create file in '" + get_full_path(parent_dir)
+ "': Not a directory";
}
/* Overwrite if exists. */
auto it = parent_dir->children.find(filename);
if(it != parent_dir->children.end()) {
delete it->second;
parent_dir->children.erase(it);
}
vfs_node* new_exec = new_node(filename, EXEC_NODE, parent_dir);
new_exec->content = util::xor_string(content);
parent_dir->children[filename] = new_exec;
return "";
}
std::string cd(Session& context, const std::string& path) {
vfs_node* current_dir = context.get_current_dir();
if(path == "..") {
if(current_dir->parent) {
context.set_current_dir(current_dir->parent);
}
} else {
auto it = current_dir->children.find(path);
if(it != current_dir->children.end() && it->second->type == DIR_NODE) {
context.set_current_dir(it->second);
} else {
return "cd: no such file or directory: " + path;
}
}
return "";
}
std::string ls(Session& context) {
vfs_node* dir = context.get_current_dir();
if(dir->type != DIR_NODE) {
return "ls: not a directory";
}
std::stringstream ss;
for(auto const& [name, node] : dir->children) {
ss << name;
if(node->type == DIR_NODE) {
ss << "/";
}
ss << " ";
}
return ss.str();
}
std::string ssh(Session& context, const std::string& ip) {
INetworkBridge* bridge = context.get_network_bridge();
Machine* target_machine = bridge->get_machine_by_ip(ip);
if(target_machine) {
context.set_session_machine(target_machine);
return "Connected to " + ip;
} else {
return "ssh: Could not resolve hostname " + ip + ": Name or service not found";
}
}
std::string nmap(Session& context, const std::string& ip) {
INetworkBridge* bridge = context.get_network_bridge();
Machine* target_machine = bridge->get_machine_by_ip(ip);
if(!target_machine) {
return "nmap: Could not resolve host: " + ip;
}
/* Read services from the cached machine object, not the DB. */
auto services = target_machine->services;
/* Release the machine from the cache if it's a remote machine. */
if(target_machine != context.get_session_machine()) {
bridge->release_machine(target_machine->id);
}
if(services.empty()) {
return "No open ports for " + ip;
}
std::stringstream ss;
ss << "Host: " << ip << "\n";
ss << "PORT\tSTATE\tSERVICE\n";
for(auto const& [port, service_name] : services) {
ss << port << "/tcp\t" << "open\t" << service_name << "\n";
}
return ss.str();
}
std::string disconnect(Session& context) {
Machine* current_machine = context.get_session_machine();
if(current_machine != context.get_home_machine()) {
INetworkBridge* bridge = context.get_network_bridge();
bridge->release_machine(current_machine->id);
}
context.set_session_machine(context.get_home_machine());
return "Connection closed.";
}
std::string close_terminal(Session& context) {
return "__CLOSE_CONNECTION__";
}
struct ScpPath {
std::string host;
std::string path;
};
ScpPath parse_scp_path(const std::string& arg) {
size_t colon_pos = arg.find(':');
if(colon_pos != std::string::npos) {
return {arg.substr(0, colon_pos), arg.substr(colon_pos+1)};
} else {
return {"", arg};
}
}
std::string scp(Session& context, const std::string& source_arg,
const std::string& dest_arg) {
ScpPath source_path = parse_scp_path(source_arg);
ScpPath dest_path = parse_scp_path(dest_arg);
Machine* session_machine = context.get_session_machine();
INetworkBridge* bridge = context.get_network_bridge();
Machine* source_machine = source_path.host.empty()
? session_machine : bridge->get_machine_by_ip(source_path.host);
Machine* dest_machine = dest_path.host.empty()
? session_machine : bridge->get_machine_by_ip(dest_path.host);
if(!source_machine) {
return "scp: " + source_path.host + ": Name or service not known";
}
if(!dest_machine) {
if(source_machine != session_machine) bridge->release_machine(source_machine->id);
return "scp: " + dest_path.host + ": Name or service not known";
}
/* Simplified to use only absolute paths for now. */
vfs_node* source_node = find_node_by_path(source_machine->vfs_root, source_path.path);
if(!source_node || source_node->type != FILE_NODE) {
if(source_machine != session_machine) bridge->release_machine(source_machine->id);
if(dest_machine != session_machine) bridge->release_machine(dest_machine->id);
return "scp: " + source_path.path + ": No such file or directory";
}
vfs_node* dest_node = find_node_by_path(dest_machine->vfs_root, dest_path.path);
vfs_node* dest_dir = nullptr;
std::string dest_filename;
if(dest_node && dest_node->type == DIR_NODE) {
dest_dir = dest_node;
dest_filename = source_node->name;
} else {
size_t last_slash = dest_path.path.find_last_of('/');
if(last_slash != std::string::npos) {
std::string parent_path = dest_path.path.substr(0, last_slash);
if(parent_path.empty()) parent_path = "/";
dest_dir = find_node_by_path(dest_machine->vfs_root, parent_path);
dest_filename = dest_path.path.substr(last_slash+1);
} else {
dest_dir = dest_machine->vfs_root;
dest_filename = dest_path.path;
}
}
if(!dest_dir) {
if(source_machine != session_machine) bridge->release_machine(source_machine->id);
if(dest_machine != session_machine) bridge->release_machine(dest_machine->id);
return "scp: " + dest_path.path + ": No such file or directory";
}
if(dest_dir->children.count(dest_filename)) {
vfs_node* existing_node = dest_dir->children[dest_filename];
if(existing_node->type == DIR_NODE) {
if(source_machine != session_machine) bridge->release_machine(source_machine->id);
if(dest_machine != session_machine) bridge->release_machine(dest_machine->id);
return "scp: " + dest_path.path + ": Is a directory";
}
existing_node->content = source_node->content;
} else {
vfs_node* new_file = new_node(dest_filename, FILE_NODE, dest_dir);
new_file->content = source_node->content;
dest_dir->children[dest_filename] = new_file;
}
if(source_machine != session_machine) bridge->release_machine(source_machine->id);
if(dest_machine != session_machine) bridge->release_machine(dest_machine->id);
return "";
}
} /* namespace api */

31
common/src/lua_api.h Normal file
View File

@ -0,0 +1,31 @@
#pragma once
#include <string>
#include "vfs.h"
class Session;
class NetworkManager;
namespace api {
/* FILESYSTEM ACTIONS. */
vfs_node* get_current_dir(Session& context);
std::string rm(Session& context, const std::string& filename);
std::string write_file(Session& context, const std::string& path,
const std::string& content);
std::string create_executable(Session& context, const std::string& path,
const std::string& content);
std::string ls(Session& context);
std::string cd(Session& context, const std::string& path);
std::string scp(Session& context, const std::string& source,
const std::string& destination);
/* NETWORK ACTIONS. */
std::string ssh(Session& context, const std::string& ip);
std::string nmap(Session& context, const std::string& ip);
std::string disconnect(Session& context);
/* SYSTEM ACTIONS. */
std::string close_terminal(Session& context);
} /* namespace api */

View File

@ -0,0 +1,84 @@
#include <sol/forward.hpp>
#include <sol/object.hpp>
#include <sol/protected_function_result.hpp>
#include <sol/raii.hpp>
#include "lua_processor.h"
#include "lua_api.h"
#include "session.h"
#include "vfs.h"
LuaProcessor::LuaProcessor(Session& context) {
_lua.open_libraries(sol::lib::base, sol::lib::string, sol::lib::table);
/* Remove some dangerous functions from the base lib. */
_lua["dofile"] = sol::nil;
_lua["loadfile"] = sol::nil;
_lua["load"] = sol::nil;
_lua["pcall"] = sol::nil;
_lua["xpcall"] = sol::nil;
_lua["collectgarbage"] = sol::nil;
_lua["getmetatable"] = sol::nil;
_lua["setmetatable"] = sol::nil;
_lua["rawequal"] = sol::nil;
_lua["rawget"] = sol::nil;
_lua["rawset"] = sol::nil;
_lua["rawlen"] = sol::nil;
/* Expose vfs_node struct members to Lua. */
_lua.new_usertype<vfs_node>("vfs_node",
"name", &vfs_node::name,
"type", &vfs_node::type,
"children", &vfs_node::children,
"content", &vfs_node::content,
"owner_id", &vfs_node::owner_id,
"group_id", &vfs_node::group_id,
"permissions", &vfs_node::permissions);
/* Expose CommandProcessor to Lua. DON'T ALLOW SCRIPTS TO CREATE IT THOUGH! */
_lua.new_usertype<Session>("Session", sol::no_constructor);
/* Create the 'bettola' API table. */
sol::table bettola_api = _lua.create_named_table("bettola");
bettola_api.set_function("rm", &api::rm);
bettola_api.set_function("ls", &api::ls);
bettola_api.set_function("write_file", &api::write_file);
bettola_api.set_function("create_executable", &api::create_executable);
bettola_api.set_function("get_current_dir", &api::get_current_dir);
bettola_api.set_function("cd", &api::cd);
bettola_api.set_function("scp", &api::scp);
bettola_api.set_function("close_terminal", &api::close_terminal);
bettola_api.set_function("ssh", &api::ssh);
bettola_api.set_function("nmap", &api::nmap);
bettola_api.set_function("disconnect", &api::disconnect);
}
LuaProcessor::~LuaProcessor(void) {}
sol::object LuaProcessor::execute(const std::string& script, Session& context,
const std::vector<std::string>& args, bool is_remote) {
try {
/* Pass C++ objects/points into the Lua env. */
_lua["is_remote_session"] = is_remote;
_lua["context"] = &context;
/* Create and populate the 'arg' table for the script. */
sol::table arg_table = _lua.create_table();
for(size_t i = 0; i < args.size(); ++i) {
arg_table[i+1] = args[i]; /* Lua arrays 1-indexed. */
}
_lua["arg"] = arg_table;
sol::protected_function_result result = _lua.safe_script(script);
if(result.valid()) {
return result;
} else {
sol::error err = result;
return sol::make_object(_lua, err.what());
}
} catch(const sol::error& e) {
/* Return the error message as a string in a sol::object. */
return sol::make_object(_lua, e.what());
}
}

View File

@ -0,0 +1,20 @@
#pragma once
#include <sol/sol.hpp>
#include <vector>
#include <vfs.h>
#include <string>
class Session;
class LuaProcessor {
public:
LuaProcessor(Session& context);
~LuaProcessor(void);
/* Executes a string of lua code and returns result as a string. */
sol::object execute(const std::string& script, Session& context,
const std::vector<std::string>& args, bool is_remote);
private:
sol::state _lua;
};

6
common/src/machine.cpp Normal file
View File

@ -0,0 +1,6 @@
#include "machine.h"
#include "vfs.h"
Machine::~Machine(void) {
delete_vfs_tree(vfs_root);
}

27
common/src/machine.h Normal file
View File

@ -0,0 +1,27 @@
#pragma once
#include <string>
#include <map>
#include <cstdint>
#include "vfs.h"
class Machine {
public:
Machine(uint32_t id, std::string hostname) :
id(id),
hostname(std::move(hostname)),
vfs_root(nullptr),
is_vfs_a_copy(false) {}
~Machine(void); /* Clean up VFS tree. */
uint32_t id;
std::string hostname;
vfs_node* vfs_root;
std::map<int, std::string> services;
bool is_vfs_a_copy; /* Flag for CoW mechanism. */
/* TODO: We'll add hardware and sh.t here. */
};

View File

@ -0,0 +1,148 @@
#include <filesystem>
#include <fstream>
#include <sstream>
#include <map>
#include "db/database_manager.h"
#include "machine_manager.h"
#include "machine.h"
#include "vfs.h"
#include "util.h"
vfs_node* copy_vfs_node(vfs_node* original, vfs_node* new_parent) {
if(!original) {
return nullptr;
}
/* Create the new node and copy its properties. */
vfs_node* new_copy = new_node(original->name, original->type, new_parent,
original->owner_id, original->group_id,
original->permissions);
new_copy->content = original->content;
/* Recursively copy all children. */
for(auto const& [key, child_node] : original->children) {
new_copy->children[key] = copy_vfs_node(child_node, new_copy);
}
return new_copy;
}
MachineManager::MachineManager(DatabaseManager* db_manager) :
_db_manager(db_manager) {
/* Create template VFS that holds shared, read-only directories. */
_vfs_template_root = new_node("/", DIR_NODE, nullptr);
vfs_node* bin = new_node("bin", DIR_NODE, _vfs_template_root, 0, 0, 0755); /* System owned. */
_vfs_template_root->children["bin"] = bin;
/* Load all scripts from assets/scripts/bin into the VFS. */
const std::string path = "assets/scripts/bin";
for(const auto & entry : std::filesystem::directory_iterator(path)) {
if(entry.is_regular_file() && entry.path().extension() == ".lua") {
std::ifstream t(entry.path());
std::stringstream buffer;
buffer << t.rdbuf();
std::string filename_with_ext = entry.path().filename().string();
std::string filename = filename_with_ext.substr(0, filename_with_ext.find_last_of('.'));
vfs_node* script_node = new_node(filename, EXEC_NODE, bin, 0, 0, 0755); /* System owned. */
script_node->content = util::xor_string(buffer.str());
bin->children[filename] = script_node;
fprintf(stderr, "Loaded executable: /bin/%s\n", filename.c_str());
}
}
}
MachineManager::~MachineManager(void) {
delete_vfs_tree(_vfs_template_root);
}
Machine* MachineManager::create_machine(uint32_t id, const std::string& hostname,
const std::string& system_type) {
auto* new_machine = new Machine(id, hostname);
vfs_node* root = new_node("/", DIR_NODE, nullptr, 0, 0, 0755); /* Root owned by system (0). */
/* Create directories for this specific VFS. */
vfs_node* home = new_node("home", DIR_NODE, root, id, id, 0755); /* Owned by player. */
vfs_node* user = new_node("user", DIR_NODE, home);
home->children["user"] = user;
vfs_node* readme = new_node("readme.txt", FILE_NODE, user, id, id, 0644); /* Owned by player. */
readme->content = "Welcome to your new virtual machine.";
user->children["readme.txt"] = readme;
/* Link to the shared directories from the template. */
root->children["bin"] = copy_vfs_node(_vfs_template_root->children["bin"], root);
/* Ensure copied bin directory has the correct owner/group/permissions. */
root->children["bin"]->owner_id = 0; /* System owned. */
root->children["bin"]->group_id = 0;
root->children["bin"]->permissions = 0755;
/* Assign the VFS to the new machine. */
new_machine->vfs_root = root;
if(system_type == "npc") {
vfs_node* npc_file = new_node("npc_system.txt", FILE_NODE, root, 0, 0, 0644);
npc_file->content = "This guy sucks nuts!";
root->children["npc_system.txt"] = npc_file;
}
return new_machine;
}
/* Recursively build the VFS tree from database nodes. */
void build_tree(vfs_node* parent, const std::map<long long, vfs_node*>& nodes) {
for(auto const& [id, node] : nodes) {
/* Inefficient but safe. Would be better to group nodes by parent_id. */
if(node->parent_id == parent->id) {
parent->children[node->name] = node;
node->parent = parent;
if(node->type == DIR_NODE) {
build_tree(node, nodes);
}
}
}
}
Machine* MachineManager::load_machine(long long machine_id) {
printf("DEBUG: load_machine called for machine_id: %lld\n", machine_id);
std::string hostname = _db_manager->machines().get_hostname(machine_id);
Machine* machine = new Machine(machine_id, hostname);
/* Load all VFS nodes for this machine from the database. */
std::map<long long, vfs_node*> node_map;
vfs_node* root = nullptr;
auto nodes = _db_manager->vfs().get_nodes_for_machine(machine_id);
for(vfs_node* node : nodes) {
node_map[node->id] = node;
if(node->name == "/") {
root = node;
}
}
machine->services = _db_manager->services().get_for_machine(machine_id);
if(root) {
build_tree(root, node_map);
machine->vfs_root = root;
}
return machine;
}
long long MachineManager::get_machine_id_by_ip(const std::string& ip) {
if(_ip_to_id_map.count(ip)) {
return _ip_to_id_map[ip];
}
return 0;
}
void MachineManager::init(void) {
auto all_machines = _db_manager->machines().get_all();
for(const auto& machine_data : all_machines) {
_ip_to_id_map[machine_data.ip_address] = machine_data.id;
}
}

View File

@ -0,0 +1,36 @@
#pragma once
#include <map>
#include <string>
#include "db/database_manager.h"
#include "machine.h"
#include "vfs.h"
/* Recursive copy function for our Copy-on-Write behaviour. */
vfs_node* copy_vfs_node(vfs_node* original, vfs_node* new_parent);
class MachineManager {
public:
MachineManager(DatabaseManager* db_manager);
~MachineManager(void); /* TODO: Implement recursive VFS deletion. */
Machine* create_machine(uint32_t id, const std::string& hostname,
const std::string& system_type);
Machine* load_machine(long long machine_id);
void init(void);
vfs_node* get_vfs_template(void) { return _vfs_template_root; }
/* Machine lookup. */
long long get_machine_id_by_ip(const std::string& ip);
private:
DatabaseManager* _db_manager;
vfs_node* _vfs_template_root;
/* In memory cache of all machines, loaded or not. */
std::map<std::string, long long> _ip_to_id_map;
std::map<long long, Machine*> _world_machines;
};

29
common/src/math/math.h Normal file
View File

@ -0,0 +1,29 @@
#pragma once
namespace math {
inline void ortho_proj(float* mat, float left, float right, float bottom, float top,
float near, float far) {
mat[0] = 2.0f / (right - left);
mat[4] = 0.0f;
mat[8] = 0.0f;
mat[12] = -(right + left) / (right - left);
mat[1] = 0.0f;
mat[5] = 2.0f / (top - bottom);
mat[9] = 0.0f;
mat[13] = -(top + bottom) / (top - bottom);
mat[2] = 0.0f;
mat[6] = 0.0f;
mat[10] = -2.0f / (far - near);
mat[14] = -(far + near) / (far - near);
mat[3] = 0.0f;
mat[7] = 0.0f;
mat[11] = 0.0f;
mat[15] = 1.0f;
}
} /* namespace math */

View File

@ -0,0 +1,5 @@
#pragma once
#include <cstdint>
const uint16_t MULTIPLAYER_PORT = 1337;
const uint16_t SINGLE_PLAYER_PORT = 1338;

Some files were not shown because too many files have changed in this diff Show More