Compare commits

..

4 Commits

Author SHA1 Message Date
50b78f8131 Significant refactors, add middleware to router 2024-12-03 21:00:07 -06:00
d82e0fdaf3 get rid of src/util 2024-12-02 21:37:40 -06:00
18e1427ed3 refactor database access 2024-12-02 21:33:47 -06:00
0a6e86e628 Total refactor 2024-11-13 18:53:05 -08:00
65 changed files with 1166 additions and 2217 deletions

View File

@ -17,7 +17,7 @@ CREATE TABLE sessions (
`token` TEXT NOT NULL UNIQUE,
`expires` INTEGER NOT NULL
);
CREATE INDEX idx_sessions_user_id ON sessions (`user_id`);
CREATE INDEX idx_sessions_token ON sessions (`token`);
DROP TABLE IF EXISTS tokens;
CREATE TABLE tokens (

View File

@ -72,6 +72,6 @@ case $1 in
drop_db
;;
*)
echo "Usage: $0 {create|populate|reset}"
echo "Usage: $0 {create|populate|reset|drop}"
;;
esac

191
public/assets/css/basic.css Normal file
View File

@ -0,0 +1,191 @@
@import 'utilities.css';
@import 'buttons.css';
@import 'forms.css';
body {
font-family: var(--main-font);
background-color: #bcc6cf;
background-image: url('/assets/img/bg.jpg');
background-attachment: fixed;
background-position: center top;
background-repeat: no-repeat;
width: 100vw;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
header#basic-header {
display: flex;
justify-content: center;
margin: 1rem 0;
}
footer {
display: flex;
justify-content: center;
align-items: center;
margin: 1rem 0;
padding: 1rem;
text-align: center;
color: #666;
& > p:not(:last-child) {
margin-right: 2rem;
}
}
span.badge {
font-size: 10px;
background-color: #f7f8fa;
color: #111111;
border-radius: 0.25rem;
padding: 0.1rem 0.25rem;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1) inset;
&.dark {
background-color: #444c55;
color: white;
}
&.green {
background-color: #a6e3a1;
}
}
.tooltip {
position: absolute;
background-color: black;
color: white;
border: 1px solid #666;
font-size: 14px;
padding: 0.5rem;
box-shadow: 0 0 0.5rem 0.1rem rgba(0, 0, 0, 0.2);
border-radius: 0.1rem;
text-align: center;
}
.tooltip-trigger {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
h1:has(.badge), h2:has(.badge), h3:has(.badge), h4:has(.badge), h5:has(.badge), h6:has(.badge) {
display: flex;
align-items: center;
& > .badge {
margin-left: 0.5rem;
}
}
.alert {
position: relative;
min-height: 1rem;
margin: 1rem 0;
background: #f8f8f9;
padding: 0.5rem 1rem;
line-height: 1.4285rem;
color: rgba(0, 0, 0, .87);
transition: opacity .1s ease, color .1s ease, background .1s ease, box-shadow .1s ease;
border-radius: .28571429rem;
box-shadow: 0 0 0 1px rgba(34, 36, 38, .22) inset, 0 0 0 0 transparent;
display: flex;
align-items: center;
justify-content: space-between;
&.success {
background-color: #f0f9eb;
color: #2c662d;
border-color: #b3dc9d;
}
&.danger {
background-color: #f9e9eb;
color: #9f3a38;
border-color: #e0b4b4;
}
&.warning {
background-color: #fff8e1;
color: #573a08;
border-color: #f9e79f;
}
&.info {
background-color: #f0f9fb;
color: #2c7fba;
border-color: #b3d7f9;
}
&.dark {
background-color: #f0f0f0;
color: #2c2c2c;
border-color: #b3b3b3;
}
a[alert-close] {
text-decoration: none;
cursor: pointer;
font-size: 2rem;
color: inherit;
}
}
a {
color: #4C0515;
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: #6C0515;
text-decoration: underline;
}
}
body::-webkit-scrollbar {
width: 0.5rem;
}
body::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
}
body::-webkit-scrollbar-thumb {
background-color: #444c55;
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.1));
border: 1px solid;
border-color: #3D444C #2F353B #2C3137;
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
}
.char-icon {
width: 32px;
height: 32px;
background-image: url('/assets/img/world/rogues.png');
&.index-0 {
background-position: 0 0;
}
&.index-1 {
background-position: -32px 0;
}
&.index-2 {
background-position: -64px 0;
}
&.index-3 {
background-position: -96px 0;
}
&.index-4 {
background-position: -128px 0;
}
}

View File

@ -1,28 +0,0 @@
#!/bin/bash
if ! command -v bun &> /dev/null
then
echo "Bun is not installed. Please install it from https://bun.sh"
exit 1
fi
if ! command -v entr &> /dev/null
then
echo "entr is not installed. Installing entr..."
# For Debian/Ubuntu-based systems
if [[ -x "$(command -v apt)" ]]; then
sudo apt update && sudo apt install entr -y
# For Red Hat-based systems
elif [[ -x "$(command -v yum)" ]]; then
sudo yum install entr -y
# For macOS with Homebrew
elif [[ -x "$(command -v brew)" ]]; then
brew install entr
else
echo "Package manager not supported. Please install entr manually."
exit 1
fi
fi
echo "Running 'find src/ | entr -s \"bunx lightningcss-cli --minify --bundle src/main.css -o dragon.css\"'..."
find src/ | entr -s 'bunx lightningcss-cli --minify --bundle src/main.css -o dragon.css'

File diff suppressed because one or more lines are too long

View File

@ -126,3 +126,16 @@
.character-select:not(:has(input[type="radio"]:checked)) > .buttons {
display: none;
}
form.logout-form {
display: inline-block;
& > button {
display: inline-block;
appearance: none;
background: none;
outline: none;
border: none;
cursor: pointer;
}
}

View File

@ -1,15 +1,6 @@
@import 'src/buttons.css';
:root {
font-size: 16px;
--main-font: Cambria, Cochin, Georgia, Times, 'Times New Roman', serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
@import 'utilities.css';
@import 'buttons.css';
@import 'forms.css';
body {
background-color: #bcc6cf;
@ -18,16 +9,13 @@ body {
background-position: center top;
background-repeat: no-repeat;
font-family: var(--main-font);
}
main#game-container {
width: 100vw;
height: 100vh;
overflow: hidden;
position: relative;
}
div#game-ui, div#game-windows {
div#ui, div#windows {
position: absolute;
top: 0;
left: 0;
@ -37,7 +25,18 @@ div#game-ui, div#game-windows {
height: 100%;
}
div#game-windows {
div#ui {
section#menu {
display: flex;
gap: 0.5rem;
& > a {
cursor: pointer;
}
}
}
div#windows {
display: flex;
align-items: center;
justify-content: center;
@ -47,11 +46,8 @@ div#game-windows {
& > div.window {
pointer-events: auto;
background-color: #bcc6cf;
background-image: url('/assets/img/bg.jpg');
background-attachment: fixed;
background-position: center top;
background-repeat: no-repeat;
box-shadow: 0px 0px 5px black;
background-image: url('/assets/img/ui/bg.webp');
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.5);
border-radius: 4px;
position: absolute;
@ -63,13 +59,15 @@ div#game-windows {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1rem 0.5rem 1rem;
padding: 0.5rem;
cursor: grab;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
.title {
font-weight: bold;
margin-right: 1rem;
user-select: none;
color: rgba(0, 0, 0, 0.5);
&:empty {
margin-right: 0;
@ -77,15 +75,20 @@ div#game-windows {
}
.close {
width: 1.5rem;
height: 1.5rem;
width: 16px;
height: 16px;
user-select: none;
cursor: pointer;
border-radius: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
}
.body {
padding: 0 1rem 1rem 1rem;
padding: 0.5rem;
&:empty {
padding: 0;
@ -95,7 +98,7 @@ div#game-windows {
}
}
canvas#game-canvas {
canvas#canvas {
width: 100%;
height: 100%;
position: absolute;

View File

@ -1,442 +0,0 @@
@import 'utilities.css';
@import 'buttons.css';
@import 'forms.css';
@import 'profile.css';
@import 'game.css';
body {
background-color: #bcc6cf;
background-image: url('/assets/img/bg.jpg');
background-attachment: fixed;
background-position: center top;
background-repeat: no-repeat;
max-width: 1640px;
min-width: 968px;
margin: 0px auto;
font-family: var(--main-font);
}
header#main-header {
height: 76px;
color: white;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
background-image: url('/assets/img/header.jpg');
h1 {
margin: 0;
padding: 0;
}
.right {
display: flex;
align-items: center;
p {
margin-right: 1rem;
}
}
}
main {
padding: 1rem;
width: 100%;
display: flex;
gap: 2rem;
#center {
flex: 1;
}
}
aside {
min-width: 200px;
.box {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 0.15rem;
padding: 0.5rem;
}
}
aside#left nav {
& > *:not(:last-child) {
margin-bottom: 0.25rem;
}
div.stack {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 0.15rem;
input[type="checkbox"] {
display: none;
&:checked ~ div.list {
display: block;
}
&:checked + label {
background-color: rgba(0, 0, 0, 0.5);
color: white;
}
}
label {
display: flex;
align-items: center;
padding: 0.5rem 1rem;
border-radius: 0.15rem;
text-decoration: none;
color: black;
transition: color, background-color 0.2s ease;
cursor: pointer;
img {
height: 18px;
margin-right: 0.25rem;
}
span.text {
display: block;
width: 100%;
}
&:hover {
color: white;
background-color: rgba(0, 0, 0, 0.3);
}
span.arrow {
position: relative;
top: 5px;
}
}
div.list {
display: none;
& > a {
display: block;
width: 100%;
padding: 0.5rem 1rem 0.5rem 1.35rem;
border-radius: 0.15rem;
text-decoration: none;
color: black;
transition: color, background-color 0.2s ease;
&:not(:last-child)::before {
content: '├';
display: inline-block;
margin-right: 0.25rem;
}
&:last-child::before {
content: '└';
display: inline-block;
position: relative;
top: 3px;
margin-right: 0.25rem;
}
&:hover {
background-color: rgba(0, 0, 0, 0.3);
}
&.active {
background-color: #444c55;
color: #ffffff;
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.1));
border: 1px solid;
border-color: #3D444C #2F353B #2C3137;
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
}
}
}
}
& > a {
display: block;
width: 100%;
padding: 0.5rem 1rem;
text-decoration: none;
color: black;
transition: color, background-color 0.2s ease;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 0.15rem;
&:has(img) {
display: flex;
align-items: center;
img {
height: 18px;
margin-right: 0.25rem;
}
}
&:hover, &.active {
color: white;
}
&:hover {
background-color: rgba(0, 0, 0, 0.3);
}
&.active {
background-color: #444c55;
color: #ffffff;
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.1));
border: 1px solid;
border-color: #3D444C #2F353B #2C3137;
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
}
}
}
footer {
display: flex;
justify-content: center;
align-items: center;
margin: 1rem 0;
padding: 1rem;
text-align: center;
color: #666;
& > p:not(:last-child) {
margin-right: 2rem;
}
}
#character {
& > .name {
display: flex;
align-items: center;
}
& > div:not(:last-child) {
margin-bottom: 0.5rem;
}
}
span.badge {
font-size: 10px;
background-color: #f7f8fa;
color: #111111;
border-radius: 0.25rem;
padding: 0.1rem 0.25rem;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1) inset;
&.dark {
background-color: #444c55;
color: white;
}
&.green {
background-color: #a6e3a1;
}
}
.char-meter {
background-color: black;
height: 16px;
min-width: 100px;
border-radius: 0.1rem;
position: relative;
& > div {
height: 100%;
border-radius: 0.1rem;
overflow: hidden;
&.hp {
background-color: #e57373;
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(139, 0, 0, 0.1));
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
border: 1px solid;
border-color: #d32f2f #c62828 #b71c1c;
}
&.mp {
background-color: #5a9bd4;
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(60, 100, 150, 0.1));
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
border: 1px solid;
border-color: #4a8ab0 #3a7a9c #2a6a88;
}
&.tp {
background-color: #f4cc67;
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.1));
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
border: 1px solid;
border-color: #C59F43 #AA8326 #957321;
}
}
}
.tooltip {
position: absolute;
background-color: black;
color: white;
border: 1px solid #666;
font-size: 14px;
padding: 0.5rem;
box-shadow: 0 0 0.5rem 0.1rem rgba(0, 0, 0, 0.2);
border-radius: 0.1rem;
text-align: center;
}
.tooltip-trigger {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
.debug-query-log {
padding: 1rem;
font-size: 14px;
color: #666;
font-family: monospace;
&:last-child {
padding-top: 0;
}
}
#center > section {
&:not(:last-child) {
padding-bottom: 1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
margin-bottom: 1rem;
}
}
h1:has(.badge), h2:has(.badge), h3:has(.badge), h4:has(.badge), h5:has(.badge), h6:has(.badge) {
display: flex;
align-items: center;
& > .badge {
margin-left: 0.5rem;
}
}
.alert {
position: relative;
min-height: 1rem;
margin: 1rem 0;
background: #f8f8f9;
padding: 0.5rem 1rem;
line-height: 1.4285rem;
color: rgba(0, 0, 0, .87);
transition: opacity .1s ease, color .1s ease, background .1s ease, box-shadow .1s ease;
border-radius: .28571429rem;
box-shadow: 0 0 0 1px rgba(34, 36, 38, .22) inset, 0 0 0 0 transparent;
display: flex;
align-items: center;
justify-content: space-between;
&.success {
background-color: #f0f9eb;
color: #2c662d;
border-color: #b3dc9d;
}
&.danger {
background-color: #f9e9eb;
color: #9f3a38;
border-color: #e0b4b4;
}
&.warning {
background-color: #fff8e1;
color: #573a08;
border-color: #f9e79f;
}
&.info {
background-color: #f0f9fb;
color: #2c7fba;
border-color: #b3d7f9;
}
&.dark {
background-color: #f0f0f0;
color: #2c2c2c;
border-color: #b3b3b3;
}
a[alert-close] {
text-decoration: none;
cursor: pointer;
font-size: 2rem;
color: inherit;
}
}
a {
color: #4C0515;
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: #6C0515;
text-decoration: underline;
}
}
body::-webkit-scrollbar {
width: 0.5rem;
}
body::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
}
body::-webkit-scrollbar-thumb {
background-color: #444c55;
background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.1));
border: 1px solid;
border-color: #3D444C #2F353B #2C3137;
box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
}
#canvas-container {
& > canvas {
display: block;
width: 100%;
height: 440px;
image-rendering: pixelated;
image-rendering: crisp-edges;
image-rendering: -webkit-optimize-contrast;
}
}
.char-icon {
width: 32px;
height: 32px;
background-image: url('/assets/img/world/rogues.png');
&.index-0 {
background-position: 0 0;
}
&.index-1 {
background-position: -32px 0;
}
&.index-2 {
background-position: -64px 0;
}
&.index-3 {
background-position: -96px 0;
}
&.index-4 {
background-position: -128px 0;
}
}

View File

@ -1,132 +0,0 @@
section.profile {
header {
text-align: center;
margin-bottom: 3rem;
h3 {
font-size: 1rem;
}
h5 {
color: rgba(0, 0, 0, 0.5);
font-size: 0.75rem;
}
}
& > div.grid {
display: flex;
gap: 1rem;
& > section {
width: 50%;
& > div:not(:last-child) {
margin-bottom: 2rem;
}
}
}
div.avatar {
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 1rem;
img {
height: 185px;
width: 185px;
}
.border {
width: 250px;
height: 250px;
position: absolute;
}
}
h4 {
text-align: center;
text-transform: uppercase;
color: white;
font-size: 0.75rem;
margin-bottom: 0.5rem;
background-image: url('/assets/img/bar.jpg');
background-position: bottom center;
padding: 0.5rem;
}
div.stats {
& > .grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.25rem;
& > div.cell {
padding: 0.25rem 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
.label {
font-size: 0.75rem;
text-transform: uppercase;
margin-right: 0.25rem;
}
}
}
}
#equipped-gear {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
div.item {
display: flex;
justify-content: center;
align-items: center;
&.i-1x1 {
width: 30px;
height: 30px;
background-image: url('/assets/img/ui/1x1.png');
}
&.i-2x2 {
width: 60px;
height: 60px;
background-image: url('/assets/img/ui/2x2.png');
}
&.i-2x3 {
width: 60px;
height: 90px;
background-image: url('/assets/img/ui/2x3.png');
}
}
& > div {
display: flex;
gap: 0.5rem;
& > div {
display: flex;
align-items: center;
justify-content: center;
width: 60px;
&.top, &.bot {
width: 60px;
height: 60px;
}
&.mid {
width: 60px;
height: 90px;
}
}
}
}
}

View File

@ -39,3 +39,8 @@
width: 960px;
margin: 0 auto;
}
.container-480 {
width: 480px;
margin: 0 auto;
}

BIN
public/assets/img/dk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

View File

@ -16,10 +16,12 @@ class WindowManager
let w = this.windows[id]
w.querySelector('header .title').innerHTML = title
w.querySelector('.body').innerHTML = content
this.bringToFront(w)
return
}
this.createWindow(id, content, title)
let w = this.createWindow(id, content, title)
this.bringToFront(w)
}
createWindow(id, content, title = '')
@ -40,12 +42,8 @@ class WindowManager
h.appendChild(ht)
// create close button
ht.insertAdjacentHTML('afterend', `
<svg class="close" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708"/>
</svg>
`)
h.querySelector('svg').addEventListener('click', () => {
ht.insertAdjacentHTML('afterend', '<a class="close"><img src="/assets/img/ui/icons/bullet_red.png"></a>')
h.querySelector('a.close').addEventListener('click', () => {
this.windows[id].remove()
delete this.windows[id]
})
@ -60,6 +58,7 @@ class WindowManager
this.makeWindowDraggable(w, this.container)
this.windows[id] = w
this.container.appendChild(w)
return w
}
makeWindowDraggable(w, c)

View File

@ -4,6 +4,7 @@
Setup
*/
define('SRC', __DIR__ . '/../src');
define('DATABASE_PATH', __DIR__ . '/../database');
require_once SRC . '/bootstrap.php';
$r = new Router;
@ -11,86 +12,279 @@ $r = new Router;
/*
Home
*/
$r->get('/', function () {
if (user()) must_have_character();
$GLOBALS['active_nav_tab'] = 'home';
echo render('layouts/basic', ['view' => 'pages/home']);
$r->get('/', function() {
if (user()) redirect('/world');
echo render('layouts/basic', ['view' => 'pages/hello']);
});
/*
Auth
*/
$r->get('/auth/register', 'auth_controller_register_get');
$r->post('/auth/register', 'auth_controller_register_post');
$r->get('/auth/login', 'auth_controller_login_get');
$r->post('/auth/login', 'auth_controller_login_post');
$r->post('/auth/logout', 'auth_controller_logout_post');
$r->get('/register', 'Actions\Auth::register_get')->middleware('guest_only');
$r->post('/register', 'Actions\Auth::register_post')->middleware('guest_only');
$r->get('/login', function() {
echo render('layouts/basic', ['view' => 'pages/auth/login']);
})->middleware('guest_only');
$r->post('/login', function() {
$errors = [];
$u = trim($_POST['u'] ?? '');
$p = $_POST['p'] ?? '';
if (empty($u)) $errors['u'][] = 'Username is required.';
if (empty($p)) $errors['p'][] = 'Password is required.';
// If there are errors at this point, send them to the page with errors flashed.
if (!empty($errors)) {
$GLOBALS['form-errors'] = $errors;
echo render('layouts/basic', ['view' => 'pages/auth/login']);
exit;
}
$user = User::find($u);
if ($user === false || !$user->check_password($p)) {
$errors['x'][] = 'Invalid username or password.';
$GLOBALS['form-errors'] = $errors;
echo render('layouts/basic', ['view' => 'pages/auth/login']);
exit;
}
$_SESSION['user'] = serialize($user);
if ($_POST['remember'] ?? false) {
$session = Session::create($user->id, strtotime('+30 days'));
if ($session === false) error_response(400);
set_cookie('remember_me', $session->token, $session->expires);
}
if ($user->char_count() === 0) {
redirect('/character/create-first');
} elseif (!change_user_character($user->char_id)) {
echo "failed to change user character (aclp)";
error_response(999);
}
redirect('/');
})->middleware('guest_only');
$r->post('/logout', function() {
Session::delete(user()->id);
unset($_SESSION['user']);
set_cookie('remember_me', '', 1);
redirect('/');
});
$r->get('/debug/logout', function() {
Session::delete(user()->id);
unset($_SESSION['user']);
set_cookie('remember_me', '', 1);
redirect('/');
});
/*
Characters
*/
$r->get('/characters', 'char_controller_list_get');
$r->post('/characters', 'char_controller_list_post');
$r->get('/character/create-first', 'char_controller_create_first_get');
$r->post('/character/create', 'char_controller_create_post');
$r->post('/character/delete', 'char_controller_delete_post');
$r->get('/characters', function() {
//echo page('chars/list', ['chars' => user()->char_list()]);
})->middleware('must_have_character');
$r->post('/characters', function() {
$char_id = (int) ($_POST['char_id'] ?? 0);
$action = $_POST['action'] ?? '';
// If the character ID is not a number, or the action is not a string, return a 400.
if (!is_numeric($char_id) || !is_string($action)) error_response(400);
// If the character ID is 0, return to the list.
if ($char_id === 0) {
flash('alert_character_list_1', ['', 'No character selected.']);
redirect('/characters');
}
// If the action is not one of the allowed actions, return a 400.
if (!in_array($action, ['select', 'delete'])) error_response(400);
// If the action is to select a character, change the user's selected character.
if ($action === 'select') {
// If the character ID is the current character, do nothing.
if ($char_id === user()->char_id || $char_id === 0) {
flash('alert_character_list_1', ['info', 'You are already using <b>' . char()->name . '</b>.']);
redirect('/characters');
}
if (!Character::belongs_to($char_id, user()->id)) error_response(999);
change_user_character($char_id);
flash('alert_character_list_1', ['success', 'Switched to <b>' . char()->name . '</b>!']);
}
// If the action is to delete a character, move to the confirmation page.
if ($action === 'delete') {
if (!Character::belongs_to($char_id, user()->id)) error_response(999);
//echo page('chars/delete', ['char' => Character::find($char_id)]);
exit;
}
redirect('/characters');
})->middleware('must_have_character');
$r->get('/character/create-first', function() {
// If the user already has a character, redirect them to the main page.
if (user()->char_count() > 0) redirect('/');
//echo page('chars/first');
})->middleware('auth_only');
$r->post('/character/create', function() {
$errors = [];
$name = trim($_POST['n'] ?? '');
/*
A name is required.
A name must be between 3 and 18 characters.
A name must contain only alphanumeric characters and spaces.
*/
if (empty($name) || strlen($name) < 3 || strlen($name) > 18 || !ctype_alnum(str_replace(' ', '', $name))) {
$errors['n'][] = 'Name is required and must be between 3 and 18 characters long and contain only alphanumeric characters and spaces.';
}
/*
A character's name must be unique.
*/
if (Character::name_exists($name)) $errors['n'][] = 'Name is already taken.';
// If there are errors at this point, send them to the page with errors flashed.
if (!empty($errors)) {
$GLOBALS['form-errors-create-character'] = $errors;
if (isset($_POST['first']) && $_POST['first'] === 'true') {
// If this is the first character, return to the first character creation page.
//echo page('chars/first');
exit;
} else {
// If this is not the first character, return to the character list page.
//echo page('chars/list', ['chars' => user()->char_list()]);
exit;
}
}
if (($char = Character::create(user()->id, $name)) === false) error_response(400);
// Create the auxiliary tables
$char->create_location();
$char->create_gear();
// Award the Adventurer title.
$char->award_title(1);
// Set the character as the user's selected character
change_user_character($char->id);
flash('alert_character_list_1', ['success', 'Character <b>' . $name . '</b> created!']);
redirect('/characters');
})->middleware('auth_only');
$r->post('/character/delete', function() {
$char_id = (int) ($_POST['char_id'] ?? 0);
// If the character ID is not a number, return a 400.
if (!is_numeric($char_id)) error_response(400);
// Ensure the character ID is valid and belongs to the user.
if (!Character::belongs_to($char_id, user()->id)) error_response(999);
$char = Character::find($char_id);
// Confirm the name matches the name of the character. CASE SENSITIVE.
if ($char['name'] !== trim($_POST['n'] ?? '')) {
flash('alert_character_list_1', ['danger', 'Failed to delete <b>' . $char['name'] . '</b>. Name confirmation did not match.']);
redirect('/characters');
}
// Delete the character
Character::delete($char_id);
// If the character being deleted is the currently selected character, select the first character.
if (user()->char_id === $char_id) {
$chars = user()->char_list();
if (count($chars) > 0) change_user_character($chars[0]['id']);
}
flash('alert_character_list_1', ['danger', 'Character <b>' . $char['name'] . '</b> deleted.']);
redirect('/characters');
})->middleware('must_have_character');
/*
World
*/
$r->get('/world', 'world_controller_get');
$r->post('/move', 'world_controller_move_post');
$r->get('/world', function() {
echo render('layouts/game');
})->middleware('must_have_character');
/*
Profile
*/
$r->get('/profile', 'profile_controller_get');
$r->get('/profile/:id', 'profile_controller_show_get');
$r->post('/move', function() {
/*
This endpoint is used to move the character around the world. The client sends a POST request with the direction
they want to move the character. The server will update the character's position in the database and return the
new position to the client.
/*
Settings
*/
$r->get('/settings', 'settings_controller_get');
We should only be using this endpoint as an AJAX request from the world page. Since we don't need all the character's
data to move them, we can just get and update their lcoation using the user's currently selected character ID.
*/
/*
Auctions
*/
$r->get('/auctions', 'auctions_controller_get');
define('directions', [
[0, -1], // Up
[0, 1], // Down
[-1, 0], // Left
[1, 0] // Right
]);
// direction must exist
$d = (int) $_POST['direction'] ?? -1;
// Update the character's position
// 0 = up, 1 = down, 2 = left, 3 = right
$x = location('x');
$y = location('y');
if (isset(directions[$d])) {
$x += directions[$d][0];
$y += directions[$d][1];
} else {
error_response(999);
}
$r = live_db()->query('UPDATE char_locations SET x = :x, y = :y WHERE char_id = :c', [
':x' => $x,
':y' => $y,
':c' => user()->char_id
]);
if ($r === false) throw new Exception('Failed to move character. (wcmp)');
json_response(['x' => $x, 'y' => $y]);
})->middleware('ajax_only')->middleware('must_have_character');
/*
UI
*/
router_post($r, '/ui/stats', 'ui_contoller_stats_post');
/*
Testing
*/
if (env('debug')) {
$r->get('/give_silver/:x', function (int $amt) {
auth_only_and_must_have_character();
wallet()->give(Currency::Silver, $amt);
redirect('/');
});
$r->get('/take_silver/:x', function (int $amt) {
auth_only_and_must_have_character();
wallet()->take(Currency::Silver, $amt);
redirect('/');
});
}
$r->get('/ui/stats', function() {
echo c_profile_stats(char());
})->middleware('ajax_only')->middleware('must_have_character');
/*
Router
*/
// [code, handler, params]
stopwatch_start('router');
// [code, handler, params, middleware]
$l = $r->lookup($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
stopwatch_stop('router');
stopwatch_start('handler');
if ($l['code'] !== 200) error_response($l['code']);
if (!empty($l['middleware'])) foreach ($l['middleware'] as $middleware) $middleware();
$l['handler'](...$l['params'] ?? []);
stopwatch_stop('handler');
/*
Cleanup

76
src/actions/auth.php Normal file
View File

@ -0,0 +1,76 @@
<?php
namespace Actions;
use \User;
class Auth
{
public static function register_get(): void
{
echo render('layouts/basic', ['view' => 'pages/auth/register']);
}
public static function register_post(): void
{
$errors = [];
$u = trim($_POST['u'] ?? '');
$e = trim($_POST['e'] ?? '');
$p = $_POST['p'] ?? '';
/*
A username is required.
A username must be at least 3 characters long and at most 18 characters long.
A username must contain only alphanumeric characters and spaces.
*/
if (empty($u) || strlen($u) < 3 || strlen($u) > 18 || !ctype_alnum(str_replace(' ', '', $u))) {
$errors['u'][] = 'Username is required and must be between 3 and 18 characters long and contain only
alphanumeric characters and spaces.';
}
/*
An email is required.
An email must be at most 255 characters long.
An email must be a valid email address.
*/
if (empty($e) || strlen($e) > 255 || !filter_var($e, FILTER_VALIDATE_EMAIL)) {
$errors['e'][] = 'Email is required must be a valid email address.';
}
/*
A password is required.
A password must be at least 6 characters long.
*/
if (empty($p) || strlen($p) < 6) {
$errors['p'][] = 'Password is required and must be at least 6 characters long.';
}
/*
A username must be unique.
*/
if (User::username_exists($u)) {
$errors['u'][] = 'Username is already taken.';
}
/*
An email must be unique.
*/
if (User::email_exists($e)) {
$errors['e'][] = 'Email is already taken.';
}
// If there are errors at this point, send them to the page with errors flashed.
if (!empty($errors)) {
$GLOBALS['form-errors'] = $errors;
echo render('layouts/basic', ['view' => 'pages/auth/register']);
exit;
}
if (\User::create($u, $e, $p) === false) error_response(400);
$_SESSION['user'] = serialize(\User::find($u));
\Wallet::create(user()->id);
redirect('/character/create-first');
}
}

View File

@ -1,37 +1,37 @@
<?php
session_start();
// SRC is defined as the path to the src/ directory from public/
define('CLASS_MAP', [
'User' => '/models/user.php',
'Character' => '/models/character.php',
'Wallet' => '/models/wallet.php'
]);
// Source libraries
/*
============================================================
Libraries
============================================================
*/
require_once SRC . '/helpers.php';
require_once SRC . '/util/env.php';
require_once SRC . '/util/database.php';
require_once SRC . '/util/auth.php';
require_once SRC . '/util/router.php';
require_once SRC . '/util/components.php';
require_once SRC . '/util/render.php';
require_once SRC . '/util/enums.php';
require_once SRC . '/components.php';
require_once SRC . '/enums.php';
// Database models
require_once SRC . '/models/session.php';
require_once SRC . '/models/token.php';
/*
============================================================
PHP Sessions
============================================================
*/
session_start();
// Controllers
require_once SRC . '/controller/char.php';
require_once SRC . '/controller/auth.php';
require_once SRC . '/controller/world.php';
require_once SRC . '/controller/settings.php';
require_once SRC . '/controller/auctions.php';
require_once SRC . '/controller/profile.php';
require_once SRC . '/controller/ui.php';
/*
============================================================
Autoloading
============================================================
*/
define('CLASS_MAP', [
'Database' => '/database.php',
'Router' => '/router.php',
'User' => '/models/user.php',
'Character' => '/models/character.php',
'Wallet' => '/models/wallet.php',
'Session' => '/models/session.php',
'Actions\Auth' => '/actions/auth.php',
]);
spl_autoload_register(function (string $class) {
if (array_key_exists($class, CLASS_MAP)) require_once SRC . CLASS_MAP[$class];
@ -41,29 +41,39 @@ spl_autoload_register(function (string $class) {
define('START_TIME', microtime(true));
/*
Load env, set error reporting, etc.
============================================================
.ENV
============================================================
*/
env_load(SRC . '/../.env');
if (env('debug') === 'true') {
if (env('debug', false)) {
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
}
stopwatch_start('bootstrap'); // Start the bootstrap stopwatch
/*
============================================================
CSRF
============================================================
*/
csrf(); // generate a CSRF token, or retrieve the current token
// Generate a new CSRF token. (if one doesn't exist, that is)
csrf();
// error any request that fails CSRF on these methods
if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'PATCH', 'DELETE'])) {
$csrf = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF'] ?? '';
if (!hash_equals($_SESSION['csrf'] ?? '', $csrf)) error_response(418);
}
// Have global counters for queries
$GLOBALS['queries'] = 0;
$GLOBALS['query_time'] = 0;
/*
============================================================
Global State
============================================================
*/
$GLOBALS['databases'] = []; // database interfaces
// Set the default page layout
page_layout('basic');
// Run auth_check to see if we're logged in, since it populates the user data in SESSION
auth_check();
stopwatch_stop('bootstrap'); // Stop the bootstrap stopwatch
// all relevant state to handling requests
$GLOBALS['state'] = [
'logged_in' => isset($_SESSION['user']) || validate_session()
];

View File

@ -117,3 +117,12 @@ function c_equipped_gear(Character $char): string
{
return render('components/equipped_gear', ['char' => $char]);
}
/**
* Render a front-end UI window with the given title and content. The WindowManager on the front end will handle
* the rest.
*/
function c_ui_window(string $title = '', string $content = ''): string
{
return render('components/ui_window', ['title' => $title, 'content' => $content]);
}

View File

@ -1,9 +0,0 @@
<?php
function auctions_controller_get()
{
auth_only();
$GLOBALS['active_nav_tab'] = 'auctions';
echo render('layouts/basic', ['view' => 'pages/auctions/index']);
}

View File

@ -1,155 +0,0 @@
<?php
/**
* Displays the registration page.
*/
function auth_controller_register_get()
{
guest_only();
echo render('layouts/basic', ['view' => 'pages/auth/register']);
}
/**
* Handles the registration form submission.
*/
function auth_controller_register_post()
{
guest_only();
csrf_ensure();
$errors = [];
$u = trim($_POST['u'] ?? '');
$e = trim($_POST['e'] ?? '');
$p = $_POST['p'] ?? '';
/*
A username is required.
A username must be at least 3 characters long and at most 18 characters long.
A username must contain only alphanumeric characters and spaces.
*/
if (empty($u) || strlen($u) < 3 || strlen($u) > 18 || !ctype_alnum(str_replace(' ', '', $u))) {
$errors['u'][] = 'Username is required and must be between 3 and 18 characters long and contain only
alphanumeric characters and spaces.';
}
/*
An email is required.
An email must be at most 255 characters long.
An email must be a valid email address.
*/
if (empty($e) || strlen($e) > 255 || !filter_var($e, FILTER_VALIDATE_EMAIL)) {
$errors['e'][] = 'Email is required must be a valid email address.';
}
/*
A password is required.
A password must be at least 6 characters long.
*/
if (empty($p) || strlen($p) < 6) {
$errors['p'][] = 'Password is required and must be at least 6 characters long.';
}
/*
A username must be unique.
*/
if (User::username_exists($u)) {
$errors['u'][] = 'Username is already taken.';
}
/*
An email must be unique.
*/
if (User::email_exists($e)) {
$errors['e'][] = 'Email is already taken.';
}
// If there are errors at this point, send them to the page with errors flashed.
if (!empty($errors)) {
$GLOBALS['form-errors'] = $errors;
echo page('auth/register');
exit;
}
if (User::create($u, $e, $p) === false) error_response(400);
$_SESSION['user'] = serialize(User::find($u));
Wallet::create(user()->id);
redirect('/character/create-first');
}
/**
* Displays the login page.
*/
function auth_controller_login_get()
{
guest_only();
echo render('layouts/basic', ['view' => 'pages/auth/login']);
}
/**
* Handles the login form submission.
*/
function auth_controller_login_post()
{
guest_only();
csrf_ensure();
$errors = [];
$u = trim($_POST['u'] ?? '');
$p = $_POST['p'] ?? '';
if (empty($u)) $errors['u'][] = 'Username is required.';
if (empty($p)) $errors['p'][] = 'Password is required.';
// If there are errors at this point, send them to the page with errors flashed.
if (!empty($errors)) {
$GLOBALS['form-errors'] = $errors;
echo render('layouts/basic', ['view' => 'pages/auth/login']);
exit;
}
$user = User::find($u);
if ($user === false || !$user->check_password($p)) {
$errors['x'][] = 'Invalid username or password.';
$GLOBALS['form-errors'] = $errors;
echo render('layouts/basic', ['view' => 'pages/auth/login']);
exit;
}
$_SESSION['user'] = serialize($user);
if ($_POST['remember'] ?? false) {
$token = token();
$expires = strtotime('+30 days');
$result = db_query(
db_auth(),
"INSERT INTO sessions (token, user_id, expires) VALUES (:t, :u, :e)",
[':t' => $token, ':u' => user()->id, ':e' => $expires]
);
if (!$result) error_response(400);
set_cookie('remember_me', $token, $expires);
}
if (user()->char_count() === 0) {
redirect('/character/create-first');
} elseif (!change_user_character(user()->char_id)) {
echo "failed to change user character (aclp)";
error_response(999);
}
redirect('/');
}
/**
* Logs the user out.
*/
function auth_controller_logout_post()
{
csrf_ensure();
session_delete(user()->id);
unset($_SESSION['user']);
set_cookie('remember_me', '', 1);
redirect('/');
}

View File

@ -1,172 +0,0 @@
<?php
/**
* Display a list of characters for the currently logged in user.
*/
function char_controller_list_get()
{
auth_only_and_must_have_character();
$GLOBALS['active_nav_tab'] = 'chars';
echo page('chars/list', ['chars' => user()->char_list()]);
}
/**
* Handle an action from the character list page.
*/
function char_controller_list_post()
{
auth_only_and_must_have_character(); csrf_ensure();
$GLOBALS['active_nav_tab'] = 'chars';
$char_id = (int) ($_POST['char_id'] ?? 0);
$action = $_POST['action'] ?? '';
// If the character ID is not a number, or the action is not a string, return a 400.
if (!is_numeric($char_id) || !is_string($action)) error_response(400);
// If the character ID is 0, return to the list.
if ($char_id === 0) {
flash('alert_character_list_1', ['', 'No character selected.']);
redirect('/characters');
}
// If the action is not one of the allowed actions, return a 400.
if (!in_array($action, ['select', 'delete'])) error_response(400);
// If the action is to select a character, change the user's selected character.
if ($action === 'select') {
// If the character ID is the current character, do nothing.
if ($char_id === user()->char_id || $char_id === 0) {
flash('alert_character_list_1', ['info', 'You are already using <b>' . char()->name . '</b>.']);
redirect('/characters');
}
if (!Character::belongs_to($char_id, user()->id)) error_response(999);
change_user_character($char_id);
flash('alert_character_list_1', ['success', 'Switched to <b>' . char()->name . '</b>!']);
}
// If the action is to delete a character, move to the confirmation page.
if ($action === 'delete') {
if (!Character::belongs_to($char_id, user()->id)) error_response(999);
echo page('chars/delete', ['char' => Character::find($char_id)]);
exit;
}
redirect('/characters');
}
/**
* Delete a character for the currently logged in user.
*/
function char_controller_delete_post()
{
auth_only_and_must_have_character(); csrf_ensure();
$char_id = (int) ($_POST['char_id'] ?? 0);
// If the character ID is not a number, return a 400.
if (!is_numeric($char_id)) error_response(400);
// Ensure the character ID is valid and belongs to the user.
if (!Character::belongs_to($char_id, user()->id)) error_response(999);
$char = Character::find($char_id);
// Confirm the name matches the name of the character. CASE SENSITIVE.
if ($char['name'] !== trim($_POST['n'] ?? '')) {
flash('alert_character_list_1', ['danger', 'Failed to delete <b>' . $char['name'] . '</b>. Name confirmation did not match.']);
redirect('/characters');
}
// Delete the character
Character::delete($char_id);
// If the character being deleted is the currently selected character, select the first character.
if (user()->char_id === $char_id) {
$chars = user()->char_list();
if (count($chars) > 0) change_user_character($chars[0]['id']);
}
flash('alert_character_list_1', ['danger', 'Character <b>' . $char['name'] . '</b> deleted.']);
redirect('/characters');
}
/**
* Form to create your first character.
*/
function char_controller_create_first_get()
{
auth_only();
$GLOBALS['active_nav_tab'] = 'chars';
// If the user already has a character, redirect them to the main page.
if (user()->char_count() > 0) redirect('/');
echo page('chars/first');
}
/**
* Create a character for the currently logged in user.
*/
function char_controller_create_post()
{
auth_only(); csrf_ensure();
$GLOBALS['active_nav_tab'] = 'chars';
$errors = [];
$name = trim($_POST['n'] ?? '');
/*
A name is required.
A name must be between 3 and 18 characters.
A name must contain only alphanumeric characters and spaces.
*/
if (empty($name) || strlen($name) < 3 || strlen($name) > 18 || !ctype_alnum(str_replace(' ', '', $name))) {
$errors['n'][] = 'Name is required and must be between 3 and 18 characters long and contain only alphanumeric characters and spaces.';
}
/*
A character's name must be unique.
*/
if (Character::name_exists($name)) $errors['n'][] = 'Name is already taken.';
// If there are errors at this point, send them to the page with errors flashed.
if (!empty($errors)) {
$GLOBALS['form-errors-create-character'] = $errors;
if (isset($_POST['first']) && $_POST['first'] === 'true') {
// If this is the first character, return to the first character creation page.
echo page('chars/first');
exit;
} else {
// If this is not the first character, return to the character list page.
echo page('chars/list', ['chars' => user()->char_list()]);
exit;
}
}
if (($char = Character::create(user()->id, $name)) === false) error_response(400);
// Create the auxiliary tables
$char->create_location();
$char->create_gear();
// Award the Adventurer title.
$char->award_title(1);
// Set the character as the user's selected character
change_user_character($char->id);
flash('alert_character_list_1', ['success', 'Character <b>' . $name . '</b> created!']);
redirect('/characters');
}

View File

@ -1,24 +0,0 @@
<?php
/**
* View your current character's profile.
*/
function profile_controller_get()
{
auth_only_and_must_have_character();
$GLOBALS['active_nav_tab'] = "profile";
echo page('profile/main');
}
/**
* View another character's profile.
*/
function profile_controller_show_get($id)
{
auth_only_and_must_have_character();
if (($char = Character::find($id)) == false) error_response(999);
if (user()->char_id == $id) redirect('/profile');
echo page('profile/show', ['c' => $char]);
}

View File

@ -1,9 +0,0 @@
<?php
function settings_controller_get()
{
auth_only();
$GLOBALS['active_nav_tab'] = 'settings';
echo render('layouts/basic', ['view' => 'pages/settings/index']);
}

View File

@ -1,7 +0,0 @@
<?php
function ui_contoller_stats_post()
{
auth_only_and_must_have_character(); ajax_only(); csrf_ensure();
echo c_profile_stats(char());
}

View File

@ -1,59 +0,0 @@
<?php
/**
* Print the world page.
*/
function world_controller_get()
{
auth_only_and_must_have_character();
echo render('layouts/game');
}
/**
* Handle a request to move a character.
*/
function world_controller_move_post()
{
/*
This endpoint is used to move the character around the world. The client sends a POST request with the direction
they want to move the character. The server will update the character's position in the database and return the
new position to the client.
We should only be using this endpoint as an AJAX request from the world page. Since we don't need all the character's
data to move them, we can just get and update their lcoation using the user's currently selected character ID.
*/
ajax_only(); auth_only(); csrf_ensure();
define('directions', [
[0, -1], // Up
[0, 1], // Down
[-1, 0], // Left
[1, 0] // Right
]);
// direction must exist
$d = (int) $_POST['direction'] ?? -1;
// Update the character's position
// 0 = up, 1 = down, 2 = left, 3 = right
$x = location('x');
$y = location('y');
if (isset(directions[$d])) {
$x += directions[$d][0];
$y += directions[$d][1];
} else {
error_response(999);
}
$r = db_query(db_live(), 'UPDATE char_locations SET x = :x, y = :y WHERE char_id = :c', [
':x' => $x,
':y' => $y,
':c' => user()->char_id
]);
if ($r === false) throw new Exception('Failed to move character. (wcmp)');
json_response(['x' => $x, 'y' => $y]);
}

78
src/database.php Normal file
View File

@ -0,0 +1,78 @@
<?php
/**
* An extension to the SQLite3 class to add our own logging and binding semantics!
*/
class Database extends SQLite3
{
public int $count = 0;
public array $log = [];
public float $query_time = 0;
public function __construct(string $db_path)
{
parent::__construct($db_path);
parent::exec('PRAGMA cache_size = 32000');
parent::exec('PRAGMA journal_mode = WAL');
parent::exec('PRAGMA temp_store = MEMORY');
}
public function query(string $query, array $params = []): SQLite3Result|false
{
$p = strpos($query, '?') !== false;
$stmt = $this->prepare($query);
if (!empty($params)) {
foreach ($params as $k => $v) {
$stmt->bindValue($p ? $k + 1 : $k, $v, $this->getSQLiteType($v));
}
}
$start = microtime(true);
$r = $stmt->execute();
$this->log($query, microtime(true) - $start);
return $r;
}
public function exec(string $query): bool
{
$start = microtime(true);
$r = parent::exec($query);
$this->log($query, microtime(true) - $start);
return $r;
}
public function exists(string $table, string $column, mixed $value, bool $case_insensitive = true): bool
{
if ($case_insensitive) {
$query = "SELECT 1 FROM $table WHERE $column = :v COLLATE NOCASE LIMIT 1";
} else {
$query = "SELECT 1 FROM $table WHERE $column = :v LIMIT 1";
}
$result = $this->query($query, [':v' => $value]);
return $result->fetchArray(SQLITE3_NUM) !== false;
}
private function log(string $query, float $time_taken): void
{
$this->count++;
$this->query_time += $time_taken;
if (env('debug', false)) {
$this->log[] = [$query, $time_taken];
}
}
private function getSQLiteType(mixed $value): int
{
return match (true) {
is_int($value) => SQLITE3_INTEGER,
is_float($value) => SQLITE3_FLOAT,
is_null($value) => SQLITE3_NULL,
default => SQLITE3_TEXT
};
}
}

View File

@ -1,9 +1,90 @@
<?php
/**
* Load the environment variables from the .env file.
*/
function env_load(string $filePath): void
{
if (!file_exists($filePath)) throw new Exception("The .env file does not exist. (el)");
$lines = file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
// Skip lines that are empty after trimming or are comments
if ($line === '' || str_starts_with($line, '#')) continue;
// Skip lines without an '=' character
if (strpos($line, '=') === false) continue;
[$name, $value] = explode('=', $line, 2);
$name = trim($name);
$value = trim($value, " \t\n\r\0\x0B\"'"); // Trim whitespace and quotes
if (!array_key_exists($name, $_SERVER) && !array_key_exists($name, $_ENV)) {
putenv("$name=$value");
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
}
}
/**
* Retrieve an environment variable.
*/
function env(string $key, mixed $default = null): mixed
{
$v = $_ENV[$key] ?? $_SERVER[$key] ?? (getenv($key) ?: $default);
return match(true) {
$v === 'true' => true,
$v === 'false' => false,
is_numeric($v) => (int) $v,
is_float($v) => (float) $v,
default => $v
};
}
/**
* Return the path to a view file.
*/
function template(string $name): string
{
return SRC . "/../templates/$name.php";
}
/**
* Render a view with the given data. Looks for `$view` through `template()`.
*/
function render(string $path_to_base_view, array $data = []): string|false
{
ob_start();
extract($data);
require template($path_to_base_view);
return ob_get_clean();
}
/**
* Get the auth database connection from GLOBALS['databases'], or create it if it doesn't exist.
*/
function auth_db(): Database
{
return $GLOBALS['databases']['auth'] ??= new Database(DATABASE_PATH . '/auth.db');
}
/**
* Get the live database connection from GLOBALS['databases'], or create it if it doesn't exist.
*/
function live_db(): Database
{
return $GLOBALS['databases']['live'] ??= new Database(DATABASE_PATH . '/live.db');
}
/**
* Generate a pretty dope token.
*/
function token($length = 32)
function token($length = 32): string
{
return bin2hex(random_bytes($length));
}
@ -11,7 +92,7 @@ function token($length = 32)
/**
* Redirect to a new location.
*/
function redirect($location)
function redirect($location): void
{
header("Location: $location");
exit;
@ -54,14 +135,6 @@ function csrf()
return $_SESSION['csrf'];
}
/**
* Verify a CSRF token.
*/
function csrf_verify($token)
{
return hash_equals($_SESSION['csrf'] ?? '', $token);
}
/**
* Create a hidden input field for CSRF tokens.
*/
@ -75,7 +148,8 @@ function csrf_field()
*/
function csrf_ensure()
{
if (!csrf_verify($_POST['csrf'] ?? '')) error_response(418);
$csrf = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF'] ?? '';
if (!hash_equals($_SESSION['csrf'] ?? '', $csrf)) error_response(418);
}
/**
@ -99,7 +173,7 @@ function set_cookie($name, $value, $expires)
function user(): User|false
{
if (empty($_SESSION['user'])) return false;
return unserialize($_SESSION['user']);
return $GLOBALS['state']['user'] ??= unserialize($_SESSION['user']);
}
/**
@ -138,7 +212,7 @@ function change_user_character(int $char_id): bool
// If the character ID is different, update the session and database
if (user()->char_id !== $char_id) {
modify_user_session('char_id', $char_id);
db_query(db_auth(), "UPDATE users SET char_id = :c WHERE id = :u", [':c' => $char_id, ':u' => user()->id]);
auth_db()->query("UPDATE users SET char_id = :c WHERE id = :u", [':c' => $char_id, ':u' => user()->id]);
}
return true;
@ -175,8 +249,7 @@ function wallet(): Wallet|false
function location($field = '')
{
if (empty($GLOBALS['location'])) {
$GLOBALS['location'] = db_query(
db_live(),
$GLOBALS['location'] = live_db()->query(
"SELECT * FROM char_locations WHERE char_id = :c",
[':c' => user()->char_id]
)->fetchArray(SQLITE3_ASSOC);
@ -196,35 +269,6 @@ function array_to_ul($array)
return "<ul>$html</ul>";
}
/**
* Start a keyed stopwatch to measure the time between two points in the code.
*/
function stopwatch_start($key)
{
if (!env('debug', false)) return;
$GLOBALS['stopwatch'][$key] = microtime(true);
}
/**
* Stop a keyed stopwatch. Stores the time in the global $stopwatch array under the key.
*/
function stopwatch_stop($key)
{
if (!env('debug', false)) return;
if (empty($GLOBALS['stopwatch'][$key])) return 0;
$GLOBALS['stopwatch'][$key] = microtime(true) - $GLOBALS['stopwatch'][$key];
}
/**
* Get the stopwatch value and format it to within 10 digits.
*/
function stopwatch_get(string $key): string
{
if (!env('debug', false)) return '';
if (empty($GLOBALS['stopwatch'][$key])) return '';
return number_format($GLOBALS['stopwatch'][$key], 10);
}
/**
* Conditional Echo; if the condition is true, echo the value. If the condition is false, echo the $or value.
*/
@ -241,22 +285,6 @@ function is_htmx(): bool
return isset($_SERVER['HTTP_HX_REQUEST']);
}
/**
* Get whether the request is an AJAX (fetch) request.
*/
function is_ajax(): bool
{
return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
}
/**
* Limit a request to AJAX only.
*/
function ajax_only(): void
{
if (!is_ajax()) error_response(418);
}
/**
* Return a JSON response with the given data.
*/
@ -289,7 +317,7 @@ function error_response(int $code): void
*/
function title(int $title_id): array|false
{
return db_query(db_live(), 'SELECT * FROM titles WHERE id = ?', [$title_id])->fetchArray();
return live_db()->query('SELECT * FROM titles WHERE id = ?', [$title_id])->fetchArray();
}
/**
@ -353,3 +381,73 @@ function parse_bbcode(string $text): array
'char_count' => $charCount
];
}
/**
* Shorthand to call fetchArray() on a SQLite3Result. Defaults to SQLITE3_ASSOC but can pass any constant to $mode.
*/
function db_fetch_array(SQLite3Result $result, int $mode = SQLITE3_ASSOC): array|false
{
return $result->fetchArray($mode);
}
/**
* Returns whether there is a valid remember cookie, and whether that cookie has a valid session token.
*/
function validate_session(): bool
{
if (!isset($_COOKIE['remember_me'])) return false;
if (($session = Session::find($_COOKIE['remember_me'])) && $session->validate()) return true;
return false;
}
/**
* Ensure this is an AJAX-only request.
*/
function ajax_only(): void
{
$header = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
if (!strtolower($header) === 'xmlhttprequest') error_response(418);
}
/**
* Mark a request as guest only.
*/
function guest_only(): void
{
if ($GLOBALS['state']['logged_in']) redirect('/');
}
/**
* Mark a request as auth'd only.
*/
function auth_only(): void
{
if (!$GLOBALS['state']['logged_in']) redirect('/auth/login');
}
/**
* Mark a request as needing to have a character selected. Automatically checks auth_only as well.
*/
function must_have_character(): void
{
auth_only();
$user = user();
// If there is a character selected, make sure the session is up to date.
if ($user->char_id !== 0) {
char();
return;
}
// if no characters, redirect to create first
if ($user->char_count() === 0) redirect('/character/create-first');
// if no character selected, select the first one
if ($user->char_id === 0) {
$char = live_db()->query(
'SELECT * FROM characters WHERE user_id = :u ORDER BY id ASC LIMIT 1',
[':u' => $user->id]
)->fetchArray(SQLITE3_ASSOC);
change_user_character($char['id']);
}
}

View File

@ -70,8 +70,7 @@ class Character
*/
public static function find(int|string $id): Character|false
{
$q = db_query(
db_live(),
$q = live_db()->query(
"SELECT * FROM characters WHERE id = :id OR name = :id COLLATE NOCASE",
[':id' => $id]
);
@ -98,13 +97,13 @@ class Character
$v = implode(', ', array_map(fn($x) => ":$x", $k));
// Create the character!
if (db_query(db_live(), "INSERT INTO characters ($f) VALUES ($v)", $data) === false) {
if (live_db()->query("INSERT INTO characters ($f) VALUES ($v)", $data) === false) {
// @TODO: Log this error
throw new Exception('Failed to create character. (cc)');
}
// Get the character ID
return Character::find(db_live()->lastInsertRowID());
return Character::find(live_db()->lastInsertRowID());
}
/**
@ -112,8 +111,7 @@ class Character
*/
public function create_location(int $x = 0, int $y = 0, int $currently = 0): bool
{
$l = db_query(
db_live(),
$l = live_db()->query(
"INSERT INTO char_locations (char_id, x, y, currently) VALUES (:i, :x, :y, :c)",
[':i' => $this->id, ':x' => $x, ':y' => $y, ':c' => $currently]
);
@ -126,7 +124,7 @@ class Character
public function create_gear(array $initialGear = []): bool
{
// @TODO implement initial gear
$g = db_query(db_live(), "INSERT INTO char_gear (char_id) VALUES (:i)", [':i' => $this->id]);
$g = live_db()->query("INSERT INTO char_gear (char_id) VALUES (:i)", [':i' => $this->id]);
return $g !== false;
}
@ -135,7 +133,7 @@ class Character
*/
public static function name_exists(string $name): bool
{
return db_exists(db_live(), 'characters', 'name', $name);
return live_db()->exists('characters', 'name', $name);
}
/**
@ -143,7 +141,7 @@ class Character
*/
public static function exists(int $id): bool
{
return db_exists(db_live(), 'characters', 'id', $id);
return live_db()->exists('characters', 'id', $id);
}
/**
@ -151,8 +149,7 @@ class Character
*/
public static function belongs_to(int $id, int $user_id): bool
{
$q = db_query(
db_live(),
$q = live_db()->query(
"SELECT 1 FROM characters WHERE id = :i AND user_id = :u LIMIT 1",
[':i' => $id, ':u' => $user_id]
);
@ -175,8 +172,7 @@ class Character
*/
public function award_title(int $title_id): bool
{
$r = db_query(
db_live(),
$r = live_db()->query(
'INSERT INTO owned_titles (`title_id`, `char_id`) VALUES (:t, :i)',
[':t' => $title_id, ':i' => $this->id]
);
@ -189,84 +185,9 @@ class Character
public static function delete(int $id)
{
// Delete the character
if (db_query(db_live(), "DELETE FROM characters WHERE id = :p", [':p' => $id]) === false) {
if (live_db()->query("DELETE FROM characters WHERE id = :p", [':p' => $id]) === false) {
throw new Exception('Failed to delete character. (C::d)');
}
// Get item IDs from the character's inventory
$items = db_query(db_live(), "SELECT item_id FROM char_inventory WHERE char_id = :p", [':p' => $id]);
// delete the character's inventory and items
while ($row = $items->fetchArray(SQLITE3_ASSOC)) {
if (db_query(db_live(), "DELETE FROM char_inventory WHERE char_id = :c", [':c' => $id]) === false) {
throw new Exception('Failed to delete character inventory. (C::d)');
}
if (db_query(db_live(), "DELETE FROM items WHERE id = :p", [':p' => $row['id']]) === false) {
throw new Exception('Failed to delete character item slots. (C::d)');
}
}
// Delete the character's location
if (db_query(db_live(), "DELETE FROM char_locations WHERE char_id = :p", [':p' => $id]) === false) {
throw new Exception('Failed to delete character location. (C::d)');
}
// Delete the character's gear
if (db_query(db_live(), "DELETE FROM char_gear WHERE char_id = :p", [':p' => $id]) === false) {
throw new Exception('Failed to delete character gear. (C::d)');
}
// Delete the character's bank
if (db_query(db_live(), "DELETE FROM char_bank WHERE char_id = :p", [':p' => $id]) === false) {
throw new Exception('Failed to delete character bank. (C::d)');
}
// Delete character's banked items
if (db_query(db_live(), "DELETE FROM char_banked_items WHERE char_id = :p", [':p' => $id]) === false) {
throw new Exception('Failed to delete character bank items. (C::d)');
}
// Delete the user's guild membership
if (db_query(db_live(), "DELETE FROM guild_members WHERE char_id = :p", [':p' => $id]) === false) {
throw new Exception('Failed to delete character guild membership. (C::d)');
}
// if the character was a guild leader, hand leadership to the next highest ranking member
$guild = db_query(db_live(), "SELECT id FROM guilds WHERE leader_id = :p", [':p' => $id])->fetchArray(SQLITE3_ASSOC);
if ($guild !== false) {
$members = db_query(db_live(), "SELECT char_id FROM guild_members WHERE guild_id = :p ORDER BY rank DESC", [':p' => $guild['id']]);
$newLeader = $members->fetchArray(SQLITE3_ASSOC);
if ($newLeader !== false) {
db_query(db_live(), "UPDATE guilds SET leader_id = :p WHERE id = :g", [':p' => $newLeader['char_id'], ':g' => $guild['id']]);
}
}
// Get a list of all pve fight IDs.
$pve = db_query(db_fights(), "SELECT id FROM pve WHERE char_id = :p", [':p' => $id]);
// Get a list of all pvp fight IDs.
$pvp = db_query(db_fights(), "SELECT id FROM pvp WHERE char1_id = :p OR char2_id = :p", [':p' => $id]);
// Delete all pve fights
while ($row = $pve->fetchArray(SQLITE3_ASSOC)) {
if (db_query(db_fights(), "DELETE FROM pve WHERE id = :p", [':p' => $row['id']]) === false) {
throw new Exception('Failed to delete pve fight. (C::d)');
}
if (db_query(db_fights(), "DELETE FROM pve_logs WHERE fight_id = :p", [':p' => $row['id']]) === false) {
throw new Exception('Failed to delete pve fight logs. (C::d)');
}
}
// Delete all pvp fights
while ($row = $pvp->fetchArray(SQLITE3_ASSOC)) {
if (db_query(db_fights(), "DELETE FROM pvp WHERE id = :p", [':p' => $row['id']]) === false) {
throw new Exception('Failed to delete pvp fight. (C::d)');
}
if (db_query(db_fights(), "DELETE FROM pvp_logs WHERE fight_id = :p", [':p' => $row['id']]) === false) {
throw new Exception('Failed to delete pvp fight logs. (C::d)');
}
}
}
/**
@ -278,8 +199,7 @@ class Character
$t = title($this->title_id);
$q = db_query(
db_live(),
$q = live_db()->query(
'SELECT awarded FROM owned_titles WHERE char_id = ? AND title_id = ? LIMIT 1',
[$this->id, $this->title_id]
);

View File

@ -1,50 +1,46 @@
<?php
/**
* Create a session for a user with a token and expiration date. Returns the token on success, or false on failure.
*/
function session_create($userId, $expires)
class Session
{
$token = token();
$result = db_query(db_auth(), "INSERT INTO sessions (token, user_id, expires) VALUES (:t, :u, :e)", [
':t' => $token,
':u' => $userId,
':e' => $expires
]);
if (!$result) return false;
return $token;
}
public function __construct(
public int $user_id,
public string $token,
public int $expires
) {}
/**
* Find a session by token.
*/
function session_find($token)
{
$result = db_query(db_auth(), "SELECT * FROM sessions WHERE token = :t", [':t' => $token]);
$session = $result->fetchArray(SQLITE3_ASSOC);
if (!$session) return false;
$result->finalize();
return $session;
}
/**
* Delete sessions by user id.
*/
function session_delete($userId)
{
return db_query(db_auth(), "DELETE FROM sessions WHERE user_id = :u", [':u' => $userId]);
}
/**
* Validate a session by token and expiration date. If expired, the session is deleted and false is returned.
*/
function session_validate($token)
{
$session = session_find($token);
if (!$session) return false;
if ($session['expires'] < time()) {
session_delete($session['user_id']);
return false;
public static function create(int $user_id, int $expires): Session|false
{
$token = token();
$result = auth_db()->query("INSERT INTO sessions (token, user_id, expires) VALUES (:t, :u, :e)", [
':t' => $token,
':u' => $user_id,
':e' => $expires
]);
if ($result === false) return false;
return new Session($user_id, $token, $expires);
}
public static function find(string $token): Session|false
{
$result = auth_db()->query("SELECT * FROM sessions WHERE token = :t", [':t' => $token]);
$session = db_fetch_array($result);
if ($session === false) return false;
$result->finalize();
return new Session($session['user_id'], $session['token'], $session['expires']);
}
public static function delete(int $user_id): SQLite3Result|false
{
return auth_db()->query("DELETE FROM sessions WHERE user_id = :u", [':u' => $user_id]);
}
public function validate(): bool
{
if (empty($this->user_id) || empty($this->token)) return false;
if ($this->expires < time()) {
self::delete($this->user_id);
return false;
}
return true;
}
return true;
}

View File

@ -1,50 +0,0 @@
<?php
/**
* Create a token for a user. Returns the token on success, or false on failure.
*/
function token_create($userId)
{
$token = token();
$result = db_query(db_auth(), "INSERT INTO tokens (token, user_id) VALUES (:t, :u)", [
':t' => $token,
':u' => $userId
]);
if (!$result) return false;
return $token;
}
/**
* Find a token by token.
*/
function token_find($token)
{
$result = db_query(db_auth(), "SELECT * FROM tokens WHERE token = :t", [':t' => $token]);
$token = $result->fetchArray(SQLITE3_ASSOC);
if (!$token) return false;
$result->finalize();
return $token;
}
/**
* Delete a token by token.
*/
function token_delete($token)
{
return db_query(db_auth(), "DELETE FROM tokens WHERE token = :t", [':t' => $token]);
}
/**
* Validate a token by token and created date. Tokens are invalid if older than 7 days.
*/
function token_validate($token)
{
$token = token_find($token);
if (!$token) return false;
if (strtotime('+7 days') < time()) {
token_delete($token['token']);
return false;
}
return true;
}

View File

@ -68,8 +68,7 @@ class User
*/
public static function find(string|int $identifier): User|false
{
$r = db_query(
db_auth(),
$r = auth_db()->query(
"SELECT * FROM users WHERE username = :i COLLATE NOCASE OR email = :i COLLATE NOCASE OR id = :i LIMIT 1",
[':i' => $identifier]
);
@ -86,7 +85,7 @@ class User
*/
public static function create(string $username, string $email, string $password, int $auth = 0): SQLite3Result|false
{
return db_query(db_auth(), "INSERT INTO users (username, email, password, auth) VALUES (:u, :e, :p, :a)", [
return auth_db()->query("INSERT INTO users (username, email, password, auth) VALUES (:u, :e, :p, :a)", [
':u' => $username,
':e' => $email,
':p' => password_hash($password, PASSWORD_ARGON2ID),
@ -107,7 +106,7 @@ class User
*/
public static function username_exists(string $username): bool
{
return db_exists(db_auth(), 'users', 'username', $username);
return auth_db()->exists('users', 'username', $username);
}
/**
@ -115,7 +114,7 @@ class User
*/
public static function email_exists(string $email): bool
{
return db_exists(db_auth(), 'users', 'email', $email);
return auth_db()->exists('users', 'email', $email);
}
/**
@ -123,8 +122,7 @@ class User
*/
public static function delete(string|int $identifier): SQLite3Result|false
{
return db_query(
db_auth(),
return auth_db()->query(
"DELETE FROM users WHERE username = :i OR email = :i OR id = :i",
[':i' => $identifier]
);
@ -135,8 +133,7 @@ class User
*/
public function char_count(): int
{
$c = db_query(
db_live(),
$c = live_db()->query(
"SELECT COUNT(*) FROM characters WHERE user_id = :u",
[':u' => $this->id]
)->fetchArray(SQLITE3_NUM);
@ -150,7 +147,7 @@ class User
*/
public function char_list(): array|false
{
$q = db_query(db_live(), "SELECT id, name, level FROM characters WHERE user_id = ?", [$this->id]);
$q = live_db()->query("SELECT id, name, level FROM characters WHERE user_id = ?", [$this->id]);
if ($q === false) throw new Exception('Failed to list characters. (U->cl)');
$c = [];

View File

@ -10,7 +10,7 @@ class Wallet
public static function find(int $user_id): Wallet|false
{
$r = db_query(db_live(), 'SELECT * FROM wallets WHERE user_id = ?', [$user_id]);
$r = live_db()->query('SELECT * FROM wallets WHERE user_id = ?', [$user_id]);
if ($r === false) throw new Exception('Failed to query wallet. (W::f)'); // badly formed query
$w = $r->fetchArray(SQLITE3_ASSOC);
if ($w === false) return false; // no wallet found
@ -19,8 +19,7 @@ class Wallet
public static function create(int $user_id, int $silver = -1, int $starGems = -1): SQLite3Result|false
{
return db_query(
db_live(),
return live_db()->query(
"INSERT INTO wallets (user_id, silver, stargem) VALUES (:u, :s, :sg)",
[
':u' => $user_id,
@ -37,7 +36,7 @@ class Wallet
{
$cs = $c->string(true);
$new = $this->{$cs} + $amt;
return db_query(db_live(), "UPDATE wallets SET $cs = ? WHERE user_id = ?", [$new, $this->user_id]);
return live_db()->query("UPDATE wallets SET $cs = ? WHERE user_id = ?", [$new, $this->user_id]);
}
/**
@ -47,6 +46,6 @@ class Wallet
{
$cs = $c->string(true);
$new = $this->{$cs} - $amt;
return db_query(db_live(), "UPDATE wallets SET $cs = ? WHERE user_id = ?", [$new < 0 ? 0 : $new, $this->user_id]);
return live_db()->query("UPDATE wallets SET $cs = ? WHERE user_id = ?", [$new < 0 ? 0 : $new, $this->user_id]);
}
}

183
src/router.php Normal file
View File

@ -0,0 +1,183 @@
<?php
/**
* A radix-trie based URI router. Seperates URIs into chunks, then turns those chunks into an efficiently parsed
* trie. Supports URI variables!
*/
class Router
{
/**
* List of valid HTTP verbs.
*/
private const VALID_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
/**
* The tree of currently registered routes.
*/
private array $routes = [];
/**
* Store the last inserted node so we can register middleware and attributes to it.
*/
private array $last_inserted_node;
/**
* Add a route to the route tree. The route must be a URI path, and contain dynamic segments
* using a colon prefix. (:id, :slug, etc)
*
* Example:
* `$r->add($routes, 'GET', '/posts/:id', function($id) { echo "Viewing post $id"; });`
*/
public function add(string $method, string $route, callable $handler): Router
{
$this->validateMethod($method);
$this->validateRoute($route);
// Expand the route into segments and make dynamic segments into a common placeholder
$segments = array_map(function($segment) {
return str_starts_with($segment, ':') ? ':x' : $segment;
}, explode('/', trim($route, '/')));
// Push each segment into the routes array as a node, except if this is the root node
$node = &$this->routes;
foreach ($segments as $segment) {
// skip an empty segment, which allows us to register handlers for the root node
if ($segment === '') continue;
$node = &$node[$segment]; // build the node tree as we go
}
// Add the handler to the last node
$node[$method] = ['handler' => $handler, 'middleware' => []];
// Store a reference to the node so we can add middleware to it.
$this->last_inserted_node = &$node[$method];
return $this;
}
/**
* Perform a lookup in the route tree for a given method and URI. Returns an array with a result code,
* a handler if found, and any dynamic parameters. Codes are 200 for success, 404 for not found, and
* 405 for method not allowed.
*
* @return array ['code', 'handler', 'params']
*/
public function lookup(string $method, string $uri): array
{
// node is a reference to our current location in the node tree
$node = $this->routes;
// params will hold any dynamic segments we find
$params = [];
// if the URI is just a slash, we can return the handler for the root node
if ($uri === '/') {
return isset($node[$method])
? ['code' => 200, 'handler' => $node[$method]['handler']]
: ['code' => 405];
}
// We'll split up the URI into segments and traverse the node tree
foreach (explode('/', trim($uri, '/')) as $segment) {
// if there is a node for this segment, move to it
if (isset($node[$segment])) {
$node = $node[$segment];
continue;
}
// if there is a dynamic segment, move to it and store the value
if (isset($node[':x'])) {
$params[] = $segment;
$node = $node[':x'];
continue;
}
// if we can't find a node for this segment, return 404
return ['code' => 404];
}
// if we found a handler for the method, return it and any params. if not, return a 405
return isset($node[$method])
? ['code' => 200, 'handler' => $node[$method]['handler'], 'params' => $params ?? [], 'middleware' => $node[$method]['middleware']]
: ['code' => 405];
}
/**
* Add a middleware function to the last inserted node's stack.
*/
public function middleware(callable $middleware): Router
{
$this->last_inserted_node['middleware'][] = $middleware;
return $this;
}
/**
* Shorthand to register a GET route.
*/
public function get(string $route, callable $handler): Router
{
return $this->add('GET', $route, $handler);
}
/**
* Shorthand to register a POST route.
*/
public function post(string $route, callable $handler): Router
{
return $this->add('POST', $route, $handler);
}
/**
* Shorthand to register a PUT route.
*/
public function put(string $route, callable $handler): Router
{
return $this->add('PUT', $route, $handler);
}
/**
* Shorthand to register a DELETE route.
*/
public function delete(string $route, callable $handler): Router
{
return $this->add('DELETE', $route, $handler);
}
/**
* Shorthand to register a PATCH route.
*/
public function patch(string $route, callable $handler): Router
{
return $this->add('PATCH', $route, $handler);
}
/**
* Validate the given method against valid HTTP verbs.
*/
private function validateMethod(string $method): void
{
if (!in_array($method, self::VALID_METHODS)) {
throw new InvalidArgumentException("Invalid HTTP method: $method");
}
}
/**
* Validate that a new route follows expected formatting.
*/
private function validateRoute(string $route): void
{
if ($route === '') {
throw new InvalidArgumentException("Route cannot be empty");
}
// Ensure route starts with a slash
if (!str_starts_with($route, '/')) {
throw new InvalidArgumentException("Route must start with a '/'");
}
// Optional: Check for consecutive dynamic segments or invalid characters
if (preg_match('/(:x.*){2,}/', $route)) {
throw new InvalidArgumentException("Invalid route pattern: consecutive dynamic segments");
}
}
}

View File

@ -1,73 +0,0 @@
<?php
/**
* Check for a user session. If $_SESSION['user'] already exists, return early. If not, check for a remember me
* cookie. If a remember me cookie exists, validate the session and set $_SESSION['user'].
*/
function auth_check()
{
if (isset($_SESSION['user'])) return true;
if (isset($_COOKIE['remember_me'])) {
$session = session_validate($_COOKIE['remember_me']);
if ($session === true) {
$user = User::find($session['user_id']);
$_SESSION['user'] = serialize($user);
return true;
}
}
return false;
}
/**
* Ensure a user is logged in, or redirect to the login page. This will also check for a remember me cookie and
* populate the $_SESSION['user'] array.
*/
function auth_only()
{
if (!auth_check()) redirect('/auth/login');
}
/**
* If there is a user logged in, redirect to the home page. Used for when we have a guest-only page.
*/
function guest_only()
{
if (auth_check()) redirect('/');
}
/**
* Ensure the user has a character selected. If they have no character, redirect to the character creation page. Otherwise,
* select the first character attached to the user.
*/
function must_have_character()
{
// If there is a character selected, make sure the session is up to date.
if (user()->char_id !== 0) {
char();
return;
}
// if no characters, redirect to create first
if (user()->char_count() === 0) redirect('/character/create-first');
// if no character selected, select the first one
if (user()->char_id === 0) {
$char = db_query(
db_live(),
'SELECT * FROM characters WHERE user_id = :u ORDER BY id ASC LIMIT 1',
[':u' => user()->id]
)->fetchArray(SQLITE3_ASSOC);
change_user_character($char['id']);
}
}
/**
* The user must be authenticated and have a character.
*/
function auth_only_and_must_have_character()
{
auth_only();
must_have_character();
}

View File

@ -1,121 +0,0 @@
<?php
define('DBP', SRC . '/../database');
/**
* Open a connection to a database.
*/
function db_open($path)
{
$db = new SQLite3($path);
// Increase cache size to 32MB
$db->exec('PRAGMA cache_size = 32000');
// Enable WAL mode
$db->exec('PRAGMA journal_mode = WAL');
// Move temp store to memory
$db->exec('PRAGMA temp_store = MEMORY');
return $db;
}
/**
* Return a connection to the auth database.
*/
function db_auth()
{
return $GLOBALS['db_auth'] ??= db_open(DBP . '/auth.db');
}
/**
* Return a connection to the live database.
*/
function db_live()
{
return $GLOBALS['db_live'] ??= db_open(DBP . '/live.db');
}
/**
* Return a connection to the fights database.
*/
function db_fights()
{
return $GLOBALS['db_fights'] ??= db_open(DBP . '/fights.db');
}
/**
* Return a connection to the blueprints database.
*/
function db_blueprints()
{
return $GLOBALS['db_blueprints'] ??= db_open(DBP . '/blueprints.db');
}
/**
* Take a SQLite3 database connection, a query string, and an array of parameters. Prepare the query and
* bind the parameters with proper type casting. Then execute the query and return the result.
*/
function db_query(SQLite3 $db, string $query, array $params = []): SQLite3Result|false
{
$p = strpos($query, '?') !== false; // are generic placeholders?
$stmt = $db->prepare($query);
if (!empty($params)) {
foreach ($params as $k => $v) $stmt->bindValue($p ? $k + 1 : $k, $v, getSQLiteType($v));
}
$start = microtime(true);
$r = $stmt->execute();
db_log($query, microtime(true) - $start);
return $r;
}
/**
* Take a SQLite3 database connection and a query string. Execute the query and return the result.
*/
function db_exec($db, $query)
{
$start = microtime(true);
$r = $db->exec($query);
db_log($query, microtime(true) - $start);
return $r;
}
/**
* Take a SQLite3 database connection, a column name, and a value. Execute a SELECT query to see if the value
* exists in the column. Return true if the value exists, false otherwise.
*/
function db_exists(SQLite3 $db, string $table, string $column, mixed $value, bool $caseInsensitive = true): bool
{
if ($caseInsensitive) {
$query = "SELECT 1 FROM $table WHERE $column = :v COLLATE NOCASE LIMIT 1";
} else {
$query = "SELECT 1 FROM $table WHERE $column = :v LIMIT 1";
}
$result = db_query($db, $query, [':v' => $value]);
return $result->fetchArray(SQLITE3_NUM) !== false;
}
/**
* Return the appropriate SQLite type casting for the value.
*/
function getSQLiteType($value): int
{
return match (true) {
is_int($value) => SQLITE3_INTEGER,
is_float($value) => SQLITE3_FLOAT,
is_null($value) => SQLITE3_NULL,
default => SQLITE3_TEXT
};
}
/**
* Log the given query string to the db debug log.
*/
function db_log($query, $timeTaken = 0)
{
$GLOBALS['queries']++;
$GLOBALS['query_time'] += $timeTaken;
if (env('debug', false)) $GLOBALS['query_log'][] = [$query, $timeTaken];
}

View File

@ -1,47 +0,0 @@
<?php
/**
* Load the environment variables from the .env file.
*/
function env_load($filePath)
{
if (!file_exists($filePath)) throw new Exception("The .env file does not exist. (el)");
$lines = file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
// Skip lines that are empty after trimming or are comments
if ($line === '' || str_starts_with($line, '#')) continue;
// Skip lines without an '=' character
if (strpos($line, '=') === false) continue;
[$name, $value] = explode('=', $line, 2);
$name = trim($name);
$value = trim($value, " \t\n\r\0\x0B\"'"); // Trim whitespace and quotes
if (!array_key_exists($name, $_SERVER) && !array_key_exists($name, $_ENV)) {
putenv("$name=$value");
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
}
}
/**
* Retrieve an environment variable.
*/
function env($key, $default = null)
{
$v = $_ENV[$key] ?? $_SERVER[$key] ?? (getenv($key) ?: $default);
return match(true) {
$v === 'true' => true,
$v === 'false' => false,
is_numeric($v) => (int) $v,
is_float($v) => (float) $v,
default => $v
};
}

View File

@ -1,37 +0,0 @@
<?php
/**
* Return the path to a view file.
*/
function template($name)
{
return SRC . "/../templates/$name.php";
}
/**
* Render a view with the given data. Looks for `$view` through `template()`.
*/
function render($pathToBaseView, $data = [])
{
ob_start();
extract($data);
require template($pathToBaseView);
return ob_get_clean();
}
/**
* Set/retrieve the current page layout in/from GLOBALS.
*/
function page_layout($layout = '')
{
if ($layout !== '') $GLOBALS['page-layout'] = $layout;
return $GLOBALS['page-layout'] ?? 'basic';
}
/**
* Shorthand to render a page with the current layout.
*/
function page($view, $data = [])
{
return render("layouts/" . page_layout(), ['view' => "pages/$view"] + $data);
}

View File

@ -1,91 +0,0 @@
<?php
class Router
{
private array $routes = [];
/**
* Add a route to the route tree. The route must be a URI path, and contain dynamic segments
* using a colon prefix. (:id, :slug, etc)
*
* Example:
* `$r->add($routes, 'GET', '/posts/:id', function($id) { echo "Viewing post $id"; });`
*/
public function add(string $method, string $route, callable $handler): Router
{
// Expand the route into segments and make dynamic segments into a common placeholder
$segments = array_map(function($segment) {
return str_starts_with($segment, ':') ? ':x' : $segment;
}, explode('/', trim($route, '/')));
// Push each segment into the routes array as a node, except if this is the root node
$node = &$this->routes;
foreach ($segments as $segment) {
// skip an empty segment, which allows us to register handlers for the root node
if ($segment === '') continue;
$node = &$node[$segment]; // build the node tree as we go
}
// Add the handler to the last node
$node[$method] = $handler;
return $this;
}
/**
* Perform a lookup in the route tree for a given method and URI. Returns an array with a result code,
* a handler if found, and any dynamic parameters. Codes are 200 for success, 404 for not found, and
* 405 for method not allowed.
*
* @return array ['code', 'handler', 'params']
*/
public function lookup(string $method, string $uri): array
{
// node is a reference to our current location in the node tree
$node = $this->routes;
// params will hold any dynamic segments we find
$params = [];
// if the URI is just a slash, we can return the handler for the root node
if ($uri === '/') {
return isset($node[$method])
? ['code' => 200, 'handler' => $node[$method], 'params' => null]
: ['code' => 405, 'handler' => null, 'params' => null];
}
// We'll split up the URI into segments and traverse the node tree
foreach (explode('/', trim($uri, '/')) as $segment) {
// if there is a node for this segment, move to it
if (isset($node[$segment])) {
$node = $node[$segment];
continue;
}
// if there is a dynamic segment, move to it and store the value
if (isset($node[':x'])) {
$params[] = $segment;
$node = $node[':x'];
continue;
}
// if we can't find a node for this segment, return 404
return ['code' => 404, 'handler' => null, 'params' => []];
}
// if we found a handler for the method, return it and any params. if not, return a 405
return isset($node[$method])
? ['code' => 200, 'handler' => $node[$method], 'params' => $params ?? []]
: ['code' => 405, 'handler' => null, 'params' => []];
}
public function get(string $route, callable $handler): Router
{
return $this->add('GET', $route, $handler);
}
public function post(string $route, callable $handler): Router
{
return $this->add('POST', $route, $handler);
}
}

View File

@ -1,11 +0,0 @@
<div class="debug-query-log">
<h3>Query Log</h3>
<p class="mb-2"><?= $GLOBALS['queries'] ?> queries were executed.</p>
<?php
if (!empty($GLOBALS['query_log']))
foreach ($GLOBALS['query_log'] as $query) {
$time = number_format($query[1], 6);
echo "<p>({$time}s) {$query[0]}</p>";
}
?>
</div>

View File

@ -1,7 +0,0 @@
<div class="debug-query-log">
<h3>Stopwatches</h3>
<p class="mb-2">Page execution took <?= number_format((microtime(true) - START_TIME), 10) ?> seconds.</p>
<p>Bootstrap: <?= stopwatch_get('bootstrap') ?> seconds</p>
<p>Router: <?= stopwatch_get('router') ?> seconds</p>
<p>Handler: <?= stopwatch_get('handler') ?> seconds</p>
</div>

View File

@ -1,18 +0,0 @@
<nav>
<?php
const links = [
['/', 'home', 'home', 'Home'],
['/world', 'world', 'earth', 'World'],
['/profile', 'profile', 'user1', 'Profile'],
['/auctions', 'auctions', 'shop', 'Auctions'],
['/characters', 'chars', 'user1', 'Characters'],
['/settings', 'settings', 'settings', 'Settings']
];
foreach (links as $link): ?>
<a href="<?= $link[0] ?>" class="<?= ce(($GLOBALS['active_nav_tab'] ?? '') == $link[1], 'active') ?>">
<img src="/assets/img/icons/<?= $link[2] ?>.png" title="<?= $link[3] ?>">
<?= $link[3] ?>
</a>
<?php endforeach; ?>
</nav>

View File

@ -1,4 +0,0 @@
<form action="/auth/logout" method="post">
<input type="hidden" name="csrf" value="<?= csrf() ?>">
<input type="submit" value="Logout" class="ui button secondary">
</form>

View File

@ -1,37 +0,0 @@
<div id="character" class="box">
<div class="name">
<?= $c->name ?>
<?php if ($c->att_points > 0): ?>
<span class="ui button primary badge ml-2 tooltip-hover" data-tooltip-content="Attribute Points"><?= $c->att_points ?></span>
<?php endif; ?>
</div>
<div>
L<?= $c->level ?> <?= $c->title()['name'] ?>
</div>
<div>
<?= wallet()->silver ?> s
</div>
<div>
<div class="char-meter">
<div class="hp" style="width: <?= percent($c->hp, $c->m_hp) ?>%"></div>
<div class="tooltip-trigger tooltip-hover" data-tooltip-content="Health<br><?= $c->hp ?> / <?= $c->m_hp ?>"></div>
</div>
</div>
<div>
<div class="char-meter">
<div class="mp" style="width: <?= percent($c->mp, $c->m_mp) ?>%"></div>
<div class="tooltip-trigger tooltip-hover" data-tooltip-content="Mana<br><?= $c->mp ?> / <?= $c->m_mp ?>"></div>
</div>
</div>
<div>
<div class="char-meter">
<div class="tp" style="width: <?= percent($c->tp, $c->m_tp) ?>%"></div>
<div class="tooltip-trigger tooltip-hover" data-tooltip-content="Travel Points<br><?= $c->tp ?> / <?= $c->m_tp ?>"></div>
</div>
</div>
</div>

View File

@ -4,54 +4,22 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dragon Knight</title>
<link rel="stylesheet" href="/assets/css/dragon.css">
<link rel="stylesheet" href="/assets/css/basic.css">
<script src="/assets/scripts/htmx.js"></script>
</head>
<body>
<header id="main-header">
<div class="left">
<h1>Dragon Knight</h1>
</div>
<div class="right">
<?php if (user()): ?>
<p>Welcome, <?= user()->username ?></p>
<?= c_logout_button() ?>
<?php else: ?>
<a class="ui button primary" href="/auth/login">Login</a>
<a class="ui button secondary" href="/auth/register">Register</a>
<?php endif; ?>
</div>
<header id="basic-header">
<img src="/assets/img/dk.png" alt="Dragon Knight" width="480">
</header>
<main>
<aside id="left">
<?php if (user() && user()->char_id > 0) echo c_left_nav($activeTab ?? 0); ?>
</aside>
<div id="center">
<?= render($view, $data) ?>
</div>
<aside id="right">
<?php if (user() && user()->char_id > 0) echo c_right_nav(); ?>
</aside>
<main id="basic-main">
<?= render($view, $data) ?>
</main>
<footer>
<p>&copy; <?= date('Y') ?> Dragon Knight</p>
<p>q<?= $GLOBALS['queries'] ?></p>
<p>qt<?= number_format($GLOBALS['query_time'], 3) ?></p>
<p>t<?= number_format((microtime(true) - START_TIME), 3) ?></p>
<p>v<?= env('version') ?></p>
<footer id="basic-footer">
&copy; <?= date('Y') ?> Dragon Knight
</footer>
<?php
if (env('debug', false)) {
echo c_debug_query_log();
}
?>
<script type="module">
import Tooltip from '/assets/scripts/tooltip.js';
Tooltip.init({

View File

@ -9,79 +9,65 @@
<script src="/assets/scripts/WindowManager.js"></script>
</head>
<body>
<main id="game-container">
<div id="game-ui">
<section id="character-hud">
<span id="character-name"><?= char()->name ?></span>
<span id="character-title">L<?= char()->level ?> <?= char()->title()['name'] ?></span>
<canvas id="canvas"></canvas>
<div class="hud-meter">
<div class="hp" style="width: <?= percent(char()->hp, char()->m_hp) ?>%"></div>
<!--<div class="tooltip-trigger tooltip-hover" data-tooltip-content="Health<br><?= char()->hp ?> / <?= char()->m_hp ?>"></div>-->
</div>
<div id="ui">
<section id="menu">
<a id="btn-chars"><img src="/assets/img/ui/icons/user.png"></a>
<a id="btn-stats"><img src="/assets/img/ui/icons/bargraph.png"></a>
<form class="logout-form" action="/logout" method="post">
<?= csrf_field() ?>
<button type="submit"><img src="/assets/img/ui/icons/stop.png"></button>
</form>
</section>
<div class="hud-meter">
<div class="mp" style="width: <?= percent(char()->mp, char()->m_mp) ?>%"></div>
<!--<div class="tooltip-trigger tooltip-hover" data-tooltip-content="Mana<br><?= char()->mp ?> / <?= char()->m_mp ?>"></div>-->
</div>
</section>
<section id="character-hud">
<span id="character-name">(<?= char()->level ?>) <?= char()->name ?>, <?= char()->title()['name'] ?></span>
<section id="menu">
<button id="stats-button" class="ui button primary">Stats</button>
<button id="rand-button" class="ui button primary">Rand</button>
</section>
</div>
<div class="hud-meter">
<div class="hp" style="width: <?= percent(char()->hp, char()->m_hp) ?>%"></div>
<!--<div class="tooltip-trigger tooltip-hover" data-tooltip-content="Health<br><?= char()->hp ?> / <?= char()->m_hp ?>"></div>-->
</div>
<div id="game-windows"></div>
<div class="hud-meter">
<div class="mp" style="width: <?= percent(char()->mp, char()->m_mp) ?>%"></div>
<!--<div class="tooltip-trigger tooltip-hover" data-tooltip-content="Mana<br><?= char()->mp ?> / <?= char()->m_mp ?>"></div>-->
</div>
</section>
</div>
<canvas id="game-canvas"></canvas>
</main>
<div id="windows"></div>
<script>
const csrf = '<?= csrf() ?>'
let WM = new WindowManager(document.getElementById('game-windows'))
const WM = new WindowManager(document.getElementById('windows'))
const statsButton = document.getElementById('stats-button')
const randButton = document.getElementById('rand-button')
const uiBtns = {
'stats': document.getElementById('btn-stats'),
'chars': document.getElementById('btn-chars')
}
statsButton.addEventListener('click', function () {
uiBtns.stats.addEventListener('click', function () {
fetch('/ui/stats', {
method: 'POST',
method: 'GET',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: `csrf=${csrf}`
}).then(response => {
if (response.ok) {
return response.text()
} else {
throw new Error('Failed to move character');
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF': csrf
}
}).then(text => {
WM.updateWindow('stats', text, 'Stats')
}).then(response => {
return response.text()
}).then(html => {
WM.updateWindow('stats', html, 'Stats')
}).catch(error => {
console.error(error)
})
})
randButton.addEventListener('click', function () {
WM.updateWindow('rand', generateRandomString(32), 'Random')
})
function generateRandomString(length = 8) {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
}
</script>
<script>
const game = {
canvas: document.getElementById('game-canvas'),
canvas: document.getElementById('canvas'),
tiles: {
size: 32,
img: new Image(),

View File

@ -1,2 +0,0 @@
<h1>Auctions</h1>
<p>@TODO</p>

View File

@ -1,4 +1,4 @@
<div class="container-960">
<div class="container-480">
<h1 class="mb-2">Login</h1>
<p class="mb-4">
@ -7,7 +7,7 @@
<?= c_form_errors() ?>
<form action="/auth/login" method="post">
<form action="/login" method="post">
<?= csrf_field() ?>
<div class="form group">
@ -20,7 +20,7 @@
</div>
<button type="submit" class="ui button primary mb-4">Login</button>
<a href="/auth/register">New adventurer? Start here!</a>
<a href="/register">New adventurer? Start here!</a>
</form>
</div>

View File

@ -1,4 +1,4 @@
<div class="container-960" autocomplete="off">
<div class="container-480" autocomplete="off">
<h1 class="mb-2">Register</h1>
<p class="mb-2">
@ -12,7 +12,7 @@
<?= c_form_errors() ?>
<form action="/auth/register" method="post">
<form action="/register" method="post">
<?= csrf_field() ?>
<div class="form group">
@ -22,7 +22,7 @@
</div>
<button type="submit" class="ui button primary mr-4">Register</button>
<a href="/auth/login">Already an adventurer? Login!</a>
<a href="/login">Already an adventurer? Login!</a>
</form>
</div>

View File

@ -0,0 +1,4 @@
<h1>Welcome!</h1>
<a class="ui button primary" href="/login">Login</a>
<a class="ui button secondary" href="/register">Register</a>

View File

@ -1,8 +0,0 @@
<?php if (!user()): ?>
<h1>Welcome!</h1>
<a href="/auth/login" class="ui button primary">Login</a>
<a href="/auth/register" class="ui button secondary">Register</a>
<?php else: ?>
<h1>Home</h1>
<p>Welcome, <?= user()->username ?>!</p>
<?php endif; ?>

View File

@ -1,2 +0,0 @@
<h1>Settings</h1>
<p>@TODO</p>

View File

@ -1,234 +0,0 @@
<h1>World</h1>
<p>Use WASD keys to move the character</p>
<p>Current location: <span id="char_x"><?= location('x') ?></span>, <span id="char_y"><?= location('y') ?></span></p>
<div id="canvas-container">
<canvas id="canvas"></canvas>
</div>
<script>
const game = {
canvas: document.getElementById('canvas'),
csrf: '<?= csrf() ?>',
tiles: {
size: 32,
img: new Image(),
cols: 3
},
sprites: {
size: 32,
img: new Image(),
cols: 6
}
}
const map = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 2, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 2, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
]
let loc_span = {
x: document.getElementById('char_x'),
y: document.getElementById('char_y')
}
let player = {
location: { x: <?= location('x') ?>, y: <?= location('y') ?> },
current: { x: <?= location('x') ?>, y: <?= location('y') ?> },
target: { x: <?= location('x') ?>, y: <?= location('y') ?> },
char: 23, sprite: { x: 0, y: 0 },
tweenDuration: 0.2, // seconds
tweenProgress: 0
}
let camera = { x: 0, y: 0 }
let visible = { x: 0, y: 0 }
game.tiles.img.src = '/assets/img/world/tiles.jpg';
game.sprites.img.src = '/assets/img/world/rogues.png';
let lastFrameTime = 0;
let fps = 0;
let debounce = false;
function getPlayerSprite() {
let col = player.char % game.sprites.cols
let row = Math.floor(player.char / game.sprites.cols)
player.sprite = { x: col * game.sprites.size, y: row * game.sprites.size }
}
function updateCanvasSize() {
game.canvas.width = game.canvas.clientWidth
game.canvas.height = game.canvas.clientHeight
visible.x = Math.ceil(game.canvas.width / game.tiles.size)
visible.y = Math.ceil(game.canvas.height / game.tiles.size)
}
function setupEventListeners() {
window.addEventListener('resize', updateCanvasSize)
window.addEventListener('keydown', handleKeyPress)
window.addEventListener('keyup', () => debounce = false)
}
// Handle keyboard input
function handleKeyPress(e) {
if (debounce) return;
debounce = true;
let moved = false;
const newPos = { ...player }
// 0 = up, 1 = down, 2 = left, 3 = right
direction = {
'w': 0,
's': 1,
'a': 2,
'd': 3,
'ArrowUp': 0,
'ArrowDown': 1,
'ArrowLeft': 2,
'ArrowRight': 3
}[e.key];
// Direction vectors: [up, down, left, right]
const dx = [0, 0, -1, 1];
const dy = [-1, 1, 0, 0];
// Calculate new position
const newX = player.location.x + dx[direction];
const newY = player.location.y + dy[direction];
if (direction !== undefined) {
// Check if the new position is outside the map bounds
if (newX < 0 || newX >= map[0].length || newY < 0 || newY >= map.length) return;
if (map[newY][newX] !== 0) return;
// Execute a POST request to /move. If successful, the server will return a new x,y position
fetch('/move', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: `direction=${direction}&csrf=${game.csrf}`
}).then(response => {
if (response.ok) {
response.json().then(data => {
player.location = { x: data.x, y: data.y }
player.target = { x: data.x, y: data.y }
player.tweenProgress = 0
loc_span.x.textContent = player.location.x
loc_span.y.textContent = player.location.y
});
} else {
throw new Error('Failed to move character');
}
})
}
}
// Update camera position
function updateCamera() {
camera.x = player.current.x * game.tiles.size - canvas.width / 2;
camera.y = player.current.y * game.tiles.size - canvas.height / 2;
// Clamp camera to map bounds
camera.x = Math.max(0, Math.min(camera.x,
map[0].length * game.tiles.size - canvas.width));
camera.y = Math.max(0, Math.min(camera.y,
map.length * game.tiles.size - canvas.height));
}
function lerp(start, end, t) {
return start + (end - start) * t;
}
// Render the game
function render(t) {
const ctx = game.canvas.getContext('2d')
// Calculate FPS
if (lastFrameTime) {
const delta = (t - lastFrameTime) / 1000;
fps = Math.round(1 / delta);
}
lastFrameTime = t;
ctx.clearRect(0, 0, game.canvas.width, game.canvas.height)
// Calculate visible tile range
const startTileX = Math.floor(camera.x / game.tiles.size)
const startTileY = Math.floor(camera.y / game.tiles.size)
const endTileX = startTileX + visible.x + 1
const endTileY = startTileY + visible.y + 1
// Only render visible tiles
for (let y = startTileY; y < endTileY; y++) {
if (y >= map.length) continue
for (let x = startTileX; x < endTileX; x++) {
if (x >= map[0].length) continue
const screenX = Math.round(x * game.tiles.size - camera.x)
const screenY = Math.round(y * game.tiles.size - camera.y)
ctx.drawImage(game.tiles.img, map[y][x] * game.tiles.size, 0, game.tiles.size, game.tiles.size,
screenX, screenY, game.tiles.size, game.tiles.size)
}
}
// Tween player position
if (player.tweenProgress < 1) {
player.tweenProgress += 1 / player.tweenDuration / 60
player.current.x = lerp(player.current.x, player.target.x, player.tweenProgress)
player.current.y = lerp(player.current.y, player.target.y, player.tweenProgress)
} else {
player.current = { x: player.current.x, y: player.current.y }
}
updateCamera()
// Render the player on top of the map using their current position
const playerX = Math.round((player.current.x * game.tiles.size) - camera.x)
const playerY = Math.round((player.current.y * game.tiles.size) - camera.y)
ctx.drawImage(game.sprites.img, player.sprite.x, player.sprite.y, game.sprites.size, game.sprites.size,
playerX, playerY, game.sprites.size, game.sprites.size)
// Render FPS counter
ctx.fillStyle = 'white';
ctx.font = '16px Arial';
ctx.fillText(`FPS: ${fps}`, game.canvas.width - 70, 20);
requestAnimationFrame(render);
}
window.addEventListener('load', () => {
getPlayerSprite()
updateCanvasSize()
setupEventListeners()
requestAnimationFrame(render)
})
</script>