Add game framework and whitebox level

This commit is contained in:
2026-06-08 23:47:32 +08:00
parent 5525343656
commit e52cf94336
24 changed files with 778 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+8
View File
@@ -0,0 +1,8 @@
#version 330 core
in vec4 v_color;
out vec4 FragColor;
void main() {
FragColor = v_color;
}
+14
View File
@@ -0,0 +1,14 @@
#version 330 core
layout (location = 0) in vec2 a_position;
layout (location = 1) in vec2 a_texCoord;
layout (location = 2) in vec4 a_color;
uniform mat4 u_view;
uniform mat4 u_projection;
out vec4 v_color;
void main() {
v_color = a_color;
gl_Position = u_projection * u_view * vec4(a_position, 0.0, 1.0);
}
+12
View File
@@ -0,0 +1,12 @@
{
"shaders": {
"sprite": {
"vertex": "sprite.vert",
"fragment": "sprite.frag"
},
"colored_quad": {
"vertex": "colored_quad.vert",
"fragment": "colored_quad.frag"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
#version 330 core
in vec2 v_texCoord;
in vec4 v_color;
uniform sampler2D u_texture;
out vec4 FragColor;
void main() {
FragColor = texture(u_texture, v_texCoord) * v_color;
}
+16
View File
@@ -0,0 +1,16 @@
#version 330 core
layout (location = 0) in vec2 a_position;
layout (location = 1) in vec2 a_texCoord;
layout (location = 2) in vec4 a_color;
uniform mat4 u_view;
uniform mat4 u_projection;
out vec2 v_texCoord;
out vec4 v_color;
void main() {
v_texCoord = a_texCoord;
v_color = a_color;
gl_Position = u_projection * u_view * vec4(a_position, 0.0, 1.0);
}
+112
View File
@@ -0,0 +1,112 @@
# Game Architecture
This project keeps the game layer separate from the Frostbite2D engine. The
engine remains in `Frostbite2D/`; game-specific code lives under `game/`.
## Directory Layout
```text
game/
├─ assets/
│ ├─ character/ Runtime character spritesheets.
│ └─ shaders/ Runtime shaders used by the game target.
├─ docs/
│ └─ ARCHITECTURE.md This document.
└─ src/
├─ actor/ Game actors such as player, enemy, projectile.
├─ combat/ Hitbox, hurtbox, damage and combat resolution types.
├─ core/ Shared config, input snapshots and asset paths.
├─ data/ Level and gameplay data definitions.
├─ movement/ Reusable movement and collision helpers.
├─ scene/ Game scenes and level orchestration.
└─ main.cpp Game executable entry point.
```
## Runtime Flow
```text
main.cpp
-> Application::init()
-> SceneManager::ReplaceScene(WhiteboxScene)
-> WhiteboxScene::onEnter()
-> CreateWhiteboxLevel()
-> create PlayerActor
-> pass PlatformWorld to PlayerActor
-> every frame:
-> PlayerActor samples InputState
-> PlayerActor moves through PlatformMover
-> PlayerActor updates animation
-> WhiteboxScene updates camera
-> WhiteboxScene draws whitebox level
```
## Module Responsibilities
`core`
- `game_config.h` stores shared tuning constants such as virtual resolution,
gravity, player speed and attack duration.
- `asset_paths.h` stores stable runtime asset paths.
- `input_state.*` converts raw SDL keyboard state into an engine-independent
`InputState` snapshot. Player logic consumes this snapshot instead of reading
SDL directly.
`movement`
- `PlatformWorld` owns level boundaries and platform rectangles.
- `MovementBody` is a reusable position/size/velocity container.
- `PlatformMover` applies gravity, clamps horizontal bounds and resolves basic
top-side platform landing.
`data`
- `LevelDefinition` groups collision and whitebox props.
- `CreateWhiteboxLevel()` currently returns the first hard-coded blockout level.
Later this can be replaced by JSON, Tiled, LDtk, or a custom editor export
without changing `PlayerActor`.
`actor`
- `PlayerActor` owns player sprites, player state and animation.
- It depends on `InputStateProvider` and `PlatformMover`, so input and movement
can be reused for enemies or changed independently.
`scene`
- `WhiteboxScene` orchestrates the current level: it loads level data, creates
actors, draws whitebox geometry and controls the camera.
`combat`
- `hitbox.h` defines early `Hitbox` and `Hurtbox` structs plus team filtering.
Full ACT combat should build from this instead of embedding hit detection in
actors.
## Current Build Target
`xmake.lua` defines `Frostbite2DGame` for Windows. It compiles all
`game/src/**.cpp` files, links against `Frostbite2D`, and copies `game/assets`
next to the executable after build.
Build command:
```powershell
xmake -b Frostbite2DGame
```
Output:
```text
build/windows/x64/release/Frostbite2DGame.exe
```
## Next Architecture Steps
1. Add `actor/enemy_actor.*` and reuse `PlatformMover`.
2. Add a `combat/CombatSystem` that collects active hitboxes and hurtboxes each
frame, resolves hits, and emits damage events.
3. Replace hard-coded animation timing in `PlayerActor` with data definitions.
4. Move `CreateWhiteboxLevel()` to an external level format once the first
whitebox loop is stable.
5. Add a `scene/gameplay_scene` base if multiple levels share spawn, camera,
combat and pause logic.
+160
View File
@@ -0,0 +1,160 @@
#include "player_actor.h"
#include "../core/asset_paths.h"
#include "../core/game_config.h"
#include <algorithm>
#include <cmath>
namespace ns_game {
namespace {
constexpr float kSpriteOffsetX = -32.0f;
constexpr float kSpriteOffsetY = -24.0f;
} // namespace
PlayerActor::PlayerActor() {
SetSize(64.0f, 104.0f);
SetTopLeftPosition(160.0f, 380.0f);
loadSprites();
}
void PlayerActor::SetMovementWorld(const PlatformWorld* world) {
movementWorld_ = world;
}
void PlayerActor::loadSprites() {
idleSprite_ = frostbite2D::Sprite::createFromFile(assets::kMaleIdle);
runSprite_ = frostbite2D::Sprite::createFromFile(assets::kMaleRun);
punchSprite_ = frostbite2D::Sprite::createFromFile(assets::kMalePunch);
auto configure = [this](frostbite2D::Ptr<frostbite2D::Sprite> sprite) {
if (!sprite) {
return;
}
sprite->SetSize(config::kPlayerFrameSize, config::kPlayerFrameSize);
sprite->SetSourceRect(frostbite2D::Rect(0.0f, 0.0f,
config::kPlayerFrameSize,
config::kPlayerFrameSize));
sprite->SetTopLeftPosition(kSpriteOffsetX, kSpriteOffsetY);
sprite->SetVisible(false);
AddChild(sprite);
};
configure(idleSprite_);
configure(runSprite_);
configure(punchSprite_);
setAnimState(AnimState::Idle);
}
void PlayerActor::OnUpdate(float deltaTime) {
deltaTime = std::min(deltaTime, 1.0f / 30.0f);
updateInput();
updatePhysics(deltaTime);
updateAnimation(deltaTime);
}
void PlayerActor::updateInput() {
const InputState input = inputProvider_.Sample();
velocity_.x = input.moveX * config::kPlayerMoveSpeed;
if (input.moveX < 0.0f) {
facingLeft_ = true;
} else if (input.moveX > 0.0f) {
facingLeft_ = false;
}
if (input.jumpPressed && grounded_) {
velocity_.y = config::kPlayerJumpSpeed;
grounded_ = false;
}
if (input.attackPressed) {
attackTimer_ = config::kAttackDuration;
setAnimState(AnimState::Punch);
}
}
void PlayerActor::updatePhysics(float deltaTime) {
if (!movementWorld_) {
return;
}
MovementBody body;
body.position = GetTopLeftPosition();
body.size = GetSize();
body.velocity = velocity_;
body.grounded = grounded_;
body = platformMover_.Step(body, *movementWorld_, config::kGravity, deltaTime);
velocity_ = body.velocity;
grounded_ = body.grounded;
SetTopLeftPosition(body.position);
}
void PlayerActor::updateAnimation(float deltaTime) {
if (attackTimer_ > 0.0f) {
attackTimer_ = std::max(0.0f, attackTimer_ - deltaTime);
setAnimState(AnimState::Punch);
} else if (std::abs(velocity_.x) > 1.0f) {
setAnimState(AnimState::Run);
} else {
setAnimState(AnimState::Idle);
}
int frameCount = 10;
float frameDuration = 0.1f;
if (animState_ == AnimState::Idle) {
frameDuration = 0.16f;
} else if (animState_ == AnimState::Punch) {
frameDuration = 0.07f;
}
animTimer_ += deltaTime;
while (animTimer_ >= frameDuration) {
animTimer_ -= frameDuration;
frameIndex_ = (frameIndex_ + 1) % frameCount;
}
auto sprite = activeSprite();
if (sprite) {
sprite->SetSourceRect(
frostbite2D::Rect(frameIndex_ * config::kPlayerFrameSize, 0.0f,
config::kPlayerFrameSize,
config::kPlayerFrameSize));
sprite->SetFlippedX(facingLeft_);
}
}
void PlayerActor::setAnimState(AnimState state) {
if (animState_ != state) {
animState_ = state;
animTimer_ = 0.0f;
frameIndex_ = 0;
}
if (idleSprite_) {
idleSprite_->SetVisible(state == AnimState::Idle);
}
if (runSprite_) {
runSprite_->SetVisible(state == AnimState::Run);
}
if (punchSprite_) {
punchSprite_->SetVisible(state == AnimState::Punch);
}
}
frostbite2D::Ptr<frostbite2D::Sprite> PlayerActor::activeSprite() const {
switch (animState_) {
case AnimState::Run:
return runSprite_;
case AnimState::Punch:
return punchSprite_;
case AnimState::Idle:
default:
return idleSprite_;
}
}
} // namespace ns_game
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include "../core/input_state.h"
#include "../movement/platform_world.h"
#include <frostbite2D/2d/actor.h>
#include <frostbite2D/2d/sprite.h>
#include <frostbite2D/types/type_math.h>
namespace ns_game {
class PlayerActor : public frostbite2D::Actor {
public:
PlayerActor();
void SetMovementWorld(const PlatformWorld* world);
void OnUpdate(float deltaTime) override;
private:
enum class AnimState {
Idle,
Run,
Punch
};
void loadSprites();
void updateInput();
void updatePhysics(float deltaTime);
void updateAnimation(float deltaTime);
void setAnimState(AnimState state);
frostbite2D::Ptr<frostbite2D::Sprite> activeSprite() const;
const PlatformWorld* movementWorld_ = nullptr;
InputStateProvider inputProvider_;
PlatformMover platformMover_;
frostbite2D::Ptr<frostbite2D::Sprite> idleSprite_;
frostbite2D::Ptr<frostbite2D::Sprite> runSprite_;
frostbite2D::Ptr<frostbite2D::Sprite> punchSprite_;
frostbite2D::Vec2 velocity_;
AnimState animState_ = AnimState::Idle;
float animTimer_ = 0.0f;
float attackTimer_ = 0.0f;
int frameIndex_ = 0;
bool facingLeft_ = false;
bool grounded_ = false;
};
} // namespace ns_game
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <frostbite2D/types/type_math.h>
namespace ns_game {
enum class Team {
Player,
Enemy
};
struct Hitbox {
frostbite2D::Rect bounds;
Team ownerTeam = Team::Player;
int damage = 1;
float activeSeconds = 0.0f;
};
struct Hurtbox {
frostbite2D::Rect bounds;
Team ownerTeam = Team::Enemy;
};
inline bool CanHit(const Hitbox& hitbox, const Hurtbox& hurtbox) {
return hitbox.ownerTeam != hurtbox.ownerTeam &&
hitbox.bounds.intersects(hurtbox.bounds);
}
} // namespace ns_game
+12
View File
@@ -0,0 +1,12 @@
#pragma once
namespace ns_game::assets {
constexpr const char* kMaleIdle =
"assets/character/male/Male_spritesheet_idle.png";
constexpr const char* kMaleRun =
"assets/character/male/Male_spritesheet_run.png";
constexpr const char* kMalePunch =
"assets/character/male/Male_spritesheet_punch_1.png";
} // namespace ns_game::assets
+13
View File
@@ -0,0 +1,13 @@
#pragma once
namespace ns_game::config {
constexpr int kVirtualWidth = 1280;
constexpr int kVirtualHeight = 720;
constexpr float kPlayerFrameSize = 128.0f;
constexpr float kPlayerMoveSpeed = 300.0f;
constexpr float kPlayerJumpSpeed = -620.0f;
constexpr float kGravity = 1800.0f;
constexpr float kAttackDuration = 0.28f;
} // namespace ns_game::config
+40
View File
@@ -0,0 +1,40 @@
#include "input_state.h"
#include <SDL2/SDL.h>
namespace ns_game {
namespace {
bool isDown(const Uint8* keys, SDL_Scancode key) {
return keys && keys[key] != 0;
}
} // namespace
InputState InputStateProvider::Sample() {
const Uint8* keys = SDL_GetKeyboardState(nullptr);
InputState state;
if (isDown(keys, SDL_SCANCODE_A) || isDown(keys, SDL_SCANCODE_LEFT)) {
state.moveX -= 1.0f;
}
if (isDown(keys, SDL_SCANCODE_D) || isDown(keys, SDL_SCANCODE_RIGHT)) {
state.moveX += 1.0f;
}
const bool jumpHeld =
isDown(keys, SDL_SCANCODE_SPACE) || isDown(keys, SDL_SCANCODE_W) ||
isDown(keys, SDL_SCANCODE_UP);
const bool attackHeld =
isDown(keys, SDL_SCANCODE_J) || isDown(keys, SDL_SCANCODE_K);
state.jumpPressed = jumpHeld && !jumpHeldLastFrame_;
state.attackPressed = attackHeld && !attackHeldLastFrame_;
jumpHeldLastFrame_ = jumpHeld;
attackHeldLastFrame_ = attackHeld;
return state;
}
} // namespace ns_game
+20
View File
@@ -0,0 +1,20 @@
#pragma once
namespace ns_game {
struct InputState {
float moveX = 0.0f;
bool jumpPressed = false;
bool attackPressed = false;
};
class InputStateProvider {
public:
InputState Sample();
private:
bool jumpHeldLastFrame_ = false;
bool attackHeldLastFrame_ = false;
};
} // namespace ns_game
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include "../movement/platform_world.h"
#include <frostbite2D/types/type_color.h>
#include <frostbite2D/types/type_math.h>
#include <vector>
namespace ns_game {
struct WhiteboxProp {
frostbite2D::Rect bounds;
frostbite2D::Color color;
};
struct LevelDefinition {
PlatformWorld collision;
std::vector<WhiteboxProp> props;
};
LevelDefinition CreateWhiteboxLevel();
} // namespace ns_game
+27
View File
@@ -0,0 +1,27 @@
#include "level_definition.h"
namespace ns_game {
LevelDefinition CreateWhiteboxLevel() {
LevelDefinition level;
level.collision.levelLeft = 0.0f;
level.collision.levelRight = 1600.0f;
level.collision.platforms = {
frostbite2D::Rect(0.0f, 600.0f, 1600.0f, 80.0f),
frostbite2D::Rect(230.0f, 500.0f, 220.0f, 28.0f),
frostbite2D::Rect(560.0f, 430.0f, 260.0f, 28.0f),
frostbite2D::Rect(930.0f, 510.0f, 260.0f, 28.0f),
frostbite2D::Rect(1250.0f, 420.0f, 230.0f, 28.0f),
};
level.props = {
{frostbite2D::Rect(118.0f, 392.0f, 88.0f, 208.0f),
frostbite2D::Color(0.26f, 0.30f, 0.34f, 1.0f)},
{frostbite2D::Rect(780.0f, 250.0f, 72.0f, 180.0f),
frostbite2D::Color(0.26f, 0.30f, 0.34f, 1.0f)},
{frostbite2D::Rect(1440.0f, 300.0f, 80.0f, 300.0f),
frostbite2D::Color(0.26f, 0.30f, 0.34f, 1.0f)},
};
return level;
}
} // namespace ns_game
+41
View File
@@ -0,0 +1,41 @@
#include "scene/whitebox_scene.h"
#include "core/game_config.h"
#include <cstdio>
#define SDL_MAIN_HANDLED
#include <frostbite2D/core/application.h>
#include <frostbite2D/scene/scene_manager.h>
int main(int argc, char** argv) {
(void)argc;
(void)argv;
auto config = frostbite2D::AppConfig::createDefault();
config.appName = "NS Unknown Game";
config.windowConfig.title = "NS Unknown Game - Whitebox";
config.windowConfig.width = 1280;
config.windowConfig.height = 720;
config.windowConfig.resizable = true;
config.windowConfig.vsync = true;
config.useVirtualResolution = true;
config.virtualWidth = ns_game::config::kVirtualWidth;
config.virtualHeight = ns_game::config::kVirtualHeight;
config.resolutionMode = frostbite2D::ResolutionScaleMode::Fit;
config.renderStyleProfile = frostbite2D::RenderStyleProfileId::PixelArt2D;
config.maxFps = 60;
auto& app = frostbite2D::Application::get();
if (!app.init(config)) {
std::puts("NS Unknown Game init failed.");
return 1;
}
app.run([]() {
frostbite2D::SceneManager::get().ReplaceScene(
frostbite2D::MakePtr<ns_game::WhiteboxScene>());
});
app.shutdown();
return 0;
}
+39
View File
@@ -0,0 +1,39 @@
#include "platform_world.h"
#include <algorithm>
namespace ns_game {
MovementBody PlatformMover::Step(const MovementBody& body,
const PlatformWorld& world, float gravity,
float deltaTime) const {
MovementBody next = body;
const frostbite2D::Vec2 previousPosition = next.position;
next.velocity.y += gravity * deltaTime;
next.position += next.velocity * deltaTime;
next.grounded = false;
const float previousBottom = previousPosition.y + next.size.y;
const float newBottom = next.position.y + next.size.y;
for (const auto& platform : world.platforms) {
const bool horizontallyOverlaps =
next.position.x + next.size.x > platform.left() &&
next.position.x < platform.right();
const bool wasAbove = previousBottom <= platform.top() + 4.0f;
const bool crossedTop = newBottom >= platform.top();
if (next.velocity.y >= 0.0f && horizontallyOverlaps && wasAbove &&
crossedTop) {
next.position.y = platform.top() - next.size.y;
next.velocity.y = 0.0f;
next.grounded = true;
}
}
next.position.x =
std::clamp(next.position.x, world.levelLeft, world.levelRight - next.size.x);
return next;
}
} // namespace ns_game
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <frostbite2D/types/type_math.h>
#include <vector>
namespace ns_game {
struct PlatformWorld {
float levelLeft = 0.0f;
float levelRight = 1280.0f;
std::vector<frostbite2D::Rect> platforms;
};
struct MovementBody {
frostbite2D::Vec2 position;
frostbite2D::Vec2 size;
frostbite2D::Vec2 velocity;
bool grounded = false;
};
class PlatformMover {
public:
MovementBody Step(const MovementBody& body, const PlatformWorld& world,
float gravity, float deltaTime) const;
};
} // namespace ns_game
+84
View File
@@ -0,0 +1,84 @@
#include "whitebox_scene.h"
#include "../core/game_config.h"
#include <frostbite2D/core/application.h>
#include <frostbite2D/graphics/camera.h>
#include <frostbite2D/graphics/renderer.h>
#include <algorithm>
namespace ns_game {
namespace {
void drawRect(const frostbite2D::Rect& rect, const frostbite2D::Color& color) {
frostbite2D::Renderer::get().drawQuad(rect, color);
}
} // namespace
void WhiteboxScene::onEnter() {
frostbite2D::Scene::onEnter();
level_ = CreateWhiteboxLevel();
player_ = frostbite2D::MakePtr<PlayerActor>();
player_->SetMovementWorld(&level_.collision);
AddChild(player_);
}
void WhiteboxScene::OnUpdate(float deltaTime) {
(void)deltaTime;
updateCamera();
}
void WhiteboxScene::Render() {
drawWhitebox();
frostbite2D::Scene::Render();
}
void WhiteboxScene::drawWhitebox() const {
const float worldWidth = level_.collision.levelRight - level_.collision.levelLeft;
drawRect(frostbite2D::Rect(level_.collision.levelLeft, 0.0f, worldWidth,
config::kVirtualHeight),
frostbite2D::Color(0.10f, 0.12f, 0.14f, 1.0f));
for (float x = level_.collision.levelLeft; x <= level_.collision.levelRight;
x += 80.0f) {
drawRect(frostbite2D::Rect(x, 0.0f, 1.0f, config::kVirtualHeight),
frostbite2D::Color(0.20f, 0.22f, 0.25f, 1.0f));
}
for (float y = 0.0f; y <= config::kVirtualHeight; y += 80.0f) {
drawRect(frostbite2D::Rect(level_.collision.levelLeft, y, worldWidth, 1.0f),
frostbite2D::Color(0.20f, 0.22f, 0.25f, 1.0f));
}
for (const auto& prop : level_.props) {
drawRect(prop.bounds, prop.color);
}
for (const auto& platform : level_.collision.platforms) {
drawRect(platform, frostbite2D::Color(0.62f, 0.68f, 0.73f, 1.0f));
drawRect(frostbite2D::Rect(platform.left(), platform.top(), platform.width(), 4.0f),
frostbite2D::Color(0.91f, 0.95f, 0.98f, 1.0f));
}
}
void WhiteboxScene::updateCamera() {
if (!player_) {
return;
}
auto* renderer = frostbite2D::Application::get().getRenderer();
if (!renderer || !renderer->getCamera()) {
return;
}
const auto playerPos = player_->GetTopLeftPosition();
const float targetX = std::clamp(playerPos.x - 420.0f,
level_.collision.levelLeft,
level_.collision.levelRight -
static_cast<float>(config::kVirtualWidth));
renderer->getCamera()->setPosition(frostbite2D::Vec2(targetX, 0.0f));
}
} // namespace ns_game
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include "../actor/player_actor.h"
#include "../data/level_definition.h"
#include <frostbite2D/scene/scene.h>
#include <frostbite2D/types/type_math.h>
namespace ns_game {
class WhiteboxScene : public frostbite2D::Scene {
public:
void onEnter() override;
void OnUpdate(float deltaTime) override;
void Render() override;
private:
void drawWhitebox() const;
void updateCamera();
LevelDefinition level_;
frostbite2D::Ptr<PlayerActor> player_;
};
} // namespace ns_game
+17
View File
@@ -31,4 +31,21 @@ if is_plat("windows") then
add_packages("zlib")
add_syslinks("imm32")
target_end()
target("Frostbite2DGame")
set_kind("binary")
add_files("game/src/**.cpp")
add_deps("Frostbite2D")
add_packages("libsdl2")
add_packages("libsdl2_image")
add_packages("libsdl2_mixer")
add_packages("libsdl2_ttf")
add_packages("glm")
add_packages("zlib")
add_syslinks("imm32")
after_build(function (target)
os.cp(path.join(os.projectdir(), "game/assets"),
path.join(target:targetdir(), "assets"))
end)
target_end()
end