Total refactor

This commit is contained in:
Sky Johnson 2024-11-13 18:53:05 -08:00
parent 87b29a3828
commit 0a6e86e628
43 changed files with 618 additions and 1522 deletions

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

@ -47,11 +47,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 +60,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 +76,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;

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.

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

@ -11,73 +11,370 @@ $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('/login');
redirect('/world');
});
/*
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', function() {
guest_only();
echo render('layouts/basic', ['view' => 'pages/auth/register']);
});
$r->post('/register', function() {
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');
});
$r->get('/login', function() {
guest_only();
echo render('layouts/basic', ['view' => 'pages/auth/login']);
});
$r->post('/login', function() {
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('/');
});
$r->post('/logout', function() {
csrf_ensure();
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() {
auth_only_and_must_have_character();
$GLOBALS['active_nav_tab'] = 'chars';
echo page('chars/list', ['chars' => user()->char_list()]);
});
$r->post('/characters', function() {
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');
});
$r->get('/character/create-first', function() {
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');
});
$r->post('/character/create', function() {
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');
});
$r->post('/character/delete', function() {
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');
});
/*
World
*/
$r->get('/world', 'world_controller_get');
$r->post('/move', 'world_controller_move_post');
$r->get('/world', function() {
auth_only_and_must_have_character();
echo render('layouts/game');
});
/*
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');
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]);
});
/*
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->post('/ui/stats', function() {
ui_guard();
echo c_profile_stats(char());
});
/*
Router

View File

@ -10,8 +10,10 @@ define('CLASS_MAP', [
'Wallet' => '/models/wallet.php'
]);
// Source libraries
require_once SRC . '/helpers.php';
stopwatch_start('bootstrap'); // Start the bootstrap stopwatch
require_once SRC . '/util/env.php';
require_once SRC . '/util/database.php';
require_once SRC . '/util/auth.php';
@ -20,19 +22,9 @@ require_once SRC . '/util/components.php';
require_once SRC . '/util/render.php';
require_once SRC . '/util/enums.php';
// Database models
require_once SRC . '/models/session.php';
require_once SRC . '/models/token.php';
// 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';
spl_autoload_register(function (string $class) {
if (array_key_exists($class, CLASS_MAP)) require_once SRC . CLASS_MAP[$class];
});
@ -51,8 +43,6 @@ if (env('debug') === 'true') {
error_reporting(E_ALL);
}
stopwatch_start('bootstrap'); // Start the bootstrap stopwatch
// Generate a new CSRF token. (if one doesn't exist, that is)
csrf();
@ -60,9 +50,6 @@ csrf();
$GLOBALS['queries'] = 0;
$GLOBALS['query_time'] = 0;
// 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();

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]);
}

View File

@ -3,7 +3,7 @@
/**
* Generate a pretty dope token.
*/
function token($length = 32)
function token($length = 32): string
{
return bin2hex(random_bytes($length));
}
@ -11,7 +11,7 @@ function token($length = 32)
/**
* Redirect to a new location.
*/
function redirect($location)
function redirect($location): void
{
header("Location: $location");
exit;
@ -353,3 +353,14 @@ function parse_bbcode(string $text): array
'char_count' => $charCount
];
}
/**
* Shorthand to verify auth, a character is selected, CSRF is correct, and it is an AJAX request. Used for
* front-end API routes.
*/
function ui_guard()
{
auth_only_and_must_have_character();
ajax_only();
csrf_ensure();
}

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

@ -18,20 +18,3 @@ function render($pathToBaseView, $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,5 +1,9 @@
<?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
{
private array $routes = [];
@ -79,11 +83,17 @@ class Router
: ['code' => 405, 'handler' => null, 'params' => []];
}
/**
* 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);

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

@ -27,8 +27,12 @@
</section>
<section id="menu">
<button id="stats-button" class="ui button primary">Stats</button>
<button id="rand-button" class="ui button primary">Rand</button>
<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 action="/logout" method="post">
<?= csrf_field() ?>
<input type="submit" value="Logout" class="ui button secondary">
</form>
</section>
</div>
@ -41,10 +45,12 @@
const csrf = '<?= csrf() ?>'
let WM = new WindowManager(document.getElementById('game-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',
headers: {
@ -64,19 +70,6 @@
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>

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

@ -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>