Checkpoint 3: deliver playable samples and complete native authoring workflows

This commit is contained in:
Emil
2026-09-18 04:29:41 +03:00
parent fad5eb4e55
commit d834cfad67
92 changed files with 8335 additions and 353 deletions
+2
View File
@@ -0,0 +1,2 @@
.faset/
Exports/
+55
View File
@@ -0,0 +1,55 @@
# Collect & Escape 2D
A small playable project using the real Faset C++ gameplay module and Box2D adapter. All geometry is built in, so this project does not need an asset import or Blender installation.
## Play
Open it from the repository root with a built Editor:
```bash
build/linux-debug/faset_editor --project examples/projects/collect-2d
```
Use **Play** to build this project's `Scripts/Gameplay.cpp` and start a separate Player. On Windows use your Windows build directory and `faset_editor.exe`. First complete the engine build setup in the User Manual.
For a direct Player build on Linux, from the repository root:
```bash
cmake -S . -B build/collect-2d -G Ninja \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \
-DFASET_GAMEPLAY_SOURCE_DIR="$PWD/examples/projects/collect-2d/Scripts"
cmake --build build/collect-2d --target faset_player faset_schema_exporter
build/collect-2d/faset_player --scene examples/projects/collect-2d/Scenes/main.scene.json
```
On Windows use a Developer shell with `clang-cl` and the Windows SDK, select `clang-cl` for both compiler options, and give `FASET_GAMEPLAY_SOURCE_DIR` an absolute path.
## Objective and controls
- A/D or left/right arrows move on X.
- **Space** jumps while a native contact supports the player block.
- Collect all three **gold cubes**. Jump onto the raised platform for the middle pickup.
- Each pickup moves to the progress display. After all three, the **red physical gate** moves out of the level.
- Reach the **green exit** to reveal the large gold victory marker. Movement stops after completion.
- **E** restores the player, pickups, gate, and victory state. Falling below the level also resets the round.
- **P** pauses, **N** advances one fixed tick, and **Escape** closes the Player.
The Player window title and console identify the controls and objective; the game uses geometric progress markers rather than a text HUD. The player is a dynamic box with zero friction, not an articulated character controller. It can rotate after impacts.
## Inspect and change the game
`project.faset.json` chooses `Scenes/main.scene.json`. The scene stores object/component IDs, transforms, physics settings, and entity references in the collector component. `Scripts/Gameplay.cpp` owns the round's transient C++ state and registers its editable schema. `speed` and `jump_speed` use metres per second.
Change JSON and restart Play to see new level data. Change C++ or its schema, then stop, build, and restart; C++ hot reload is not implemented. Runtime pickup progress does not rewrite the authoring scene or create Undo entries.
## Verify
The normal test build creates `faset_example_2d_tests`. It compiles this exact module, follows a route using movement/jump input, collects the elevated pickup, opens the gate, reaches the exit, resets the round, and restarts the scene. It does not teleport to pass the objective.
```bash
cmake --build build/linux-debug --target faset_example_2d_tests
ctest --test-dir build/linux-debug -R '^playable_2d$' --output-on-failure
```
The runtime test is independent of graphics. A Player run additionally requires the supported Vulkan platform setup. The Editor's Export action creates a separate Release build and a cooked standalone generation; run that generation's Player from its export directory.
@@ -0,0 +1,659 @@
{
"format": "faset.scene",
"version": 1,
"id": "example-collect-2d-scene",
"name": "Collect & Escape 2D \u2014 WASD / Space / E reset",
"dimension": 2,
"simulation": {
"fixed_delta": 0.016666666666666666,
"max_catch_up_ticks": 4,
"physics_substeps": 4,
"gravity": [
0,
-9.81,
0
]
},
"entities": [
{
"id": "floor",
"name": "Ground",
"parent": null,
"components": [
{
"id": "floor-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
0,
-0.75,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "floor-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
0.2,
0.3,
0.39,
1
],
"size": [
21,
0.5
],
"texture": "",
"layer": 0
}
},
{
"id": "floor-faset.rigid_body_2d",
"type": "faset.rigid_body_2d",
"version": 1,
"fields": {
"body_type": "static",
"half_extents": [
10.5,
0.25
],
"density": 1,
"friction": 0.4,
"restitution": 0,
"gravity_scale": 1
}
}
]
},
{
"id": "platform",
"name": "Jump platform",
"parent": null,
"components": [
{
"id": "platform-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
0,
0,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "platform-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
0.35,
0.44,
0.55,
1
],
"size": [
2,
1
],
"texture": "",
"layer": 0
}
},
{
"id": "platform-faset.rigid_body_2d",
"type": "faset.rigid_body_2d",
"version": 1,
"fields": {
"body_type": "static",
"half_extents": [
1.0,
0.5
],
"density": 1,
"friction": 0.4,
"restitution": 0,
"gravity_scale": 1
}
}
]
},
{
"id": "left-wall",
"name": "Left boundary",
"parent": null,
"components": [
{
"id": "left-wall-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
-10.25,
1,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "left-wall-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
0.2,
0.3,
0.39,
1
],
"size": [
0.5,
3.5
],
"texture": "",
"layer": 0
}
},
{
"id": "left-wall-faset.rigid_body_2d",
"type": "faset.rigid_body_2d",
"version": 1,
"fields": {
"body_type": "static",
"half_extents": [
0.25,
1.75
],
"density": 1,
"friction": 0.4,
"restitution": 0,
"gravity_scale": 1
}
}
]
},
{
"id": "right-wall",
"name": "Right boundary",
"parent": null,
"components": [
{
"id": "right-wall-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
10.25,
1,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "right-wall-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
0.2,
0.3,
0.39,
1
],
"size": [
0.5,
3.5
],
"texture": "",
"layer": 0
}
},
{
"id": "right-wall-faset.rigid_body_2d",
"type": "faset.rigid_body_2d",
"version": 1,
"fields": {
"body_type": "static",
"half_extents": [
0.25,
1.75
],
"density": 1,
"friction": 0.4,
"restitution": 0,
"gravity_scale": 1
}
}
]
},
{
"id": "token_1",
"name": "Gold pickup 1",
"parent": null,
"components": [
{
"id": "token_1-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
-5,
0.35,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "token_1-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
1,
0.68,
0.14,
1
],
"size": [
0.5,
0.5
],
"texture": "",
"layer": 0
}
}
]
},
{
"id": "token_2",
"name": "Gold pickup 2",
"parent": null,
"components": [
{
"id": "token_2-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
0,
1.35,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "token_2-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
1,
0.68,
0.14,
1
],
"size": [
0.5,
0.5
],
"texture": "",
"layer": 0
}
}
]
},
{
"id": "token_3",
"name": "Gold pickup 3",
"parent": null,
"components": [
{
"id": "token_3-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
5,
0.35,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "token_3-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
1,
0.68,
0.14,
1
],
"size": [
0.5,
0.5
],
"texture": "",
"layer": 0
}
}
]
},
{
"id": "gate",
"name": "Collect all three to open",
"parent": null,
"components": [
{
"id": "gate-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
7,
1,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "gate-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
0.9,
0.23,
0.16,
1
],
"size": [
0.45,
3
],
"texture": "",
"layer": 0
}
},
{
"id": "gate-faset.rigid_body_2d",
"type": "faset.rigid_body_2d",
"version": 1,
"fields": {
"body_type": "static",
"half_extents": [
0.225,
1.5
],
"density": 1,
"friction": 0.4,
"restitution": 0,
"gravity_scale": 1
}
}
]
},
{
"id": "goal",
"name": "Green exit",
"parent": null,
"components": [
{
"id": "goal-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
8.5,
0.15,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "goal-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
0.15,
0.9,
0.47,
1
],
"size": [
0.85,
0.3
],
"texture": "",
"layer": 0
}
}
]
},
{
"id": "win_marker",
"name": "Victory marker",
"parent": null,
"components": [
{
"id": "win_marker-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
8.5,
3.0,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "win_marker-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
1,
0.68,
0.14,
1
],
"size": [
1,
1
],
"texture": "",
"layer": 0
}
}
]
},
{
"id": "player",
"name": "Blue player block",
"parent": null,
"components": [
{
"id": "player-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
-8,
0,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "player-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
0.12,
0.65,
1,
1
],
"size": [
1,
1
],
"texture": "",
"layer": 0
}
},
{
"id": "player-faset.rigid_body_2d",
"type": "faset.rigid_body_2d",
"version": 1,
"fields": {
"body_type": "dynamic",
"half_extents": [
0.5,
0.5
],
"density": 1,
"friction": 0,
"restitution": 0,
"gravity_scale": 1
}
},
{
"id": "player-example.collector_2d",
"type": "example.collector_2d",
"version": 1,
"fields": {
"speed": 4,
"jump_speed": 6,
"gate": "gate",
"goal": "goal",
"win_marker": "win_marker",
"token_1": "token_1",
"token_2": "token_2",
"token_3": "token_3"
}
}
]
}
],
"instances": []
}
@@ -0,0 +1,157 @@
#include "Gameplay.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <iostream>
#include <map>
#include <stdexcept>
#include <tuple>
namespace faset::gameplay {
namespace {
constexpr bool is3D = false;
constexpr const char* collectorType = "example.collector_2d";
using Key = std::tuple<std::uint64_t, std::uint32_t, std::uint64_t>;
Key key(runtime::EntityHandle handle) {
return {handle.session, handle.slot, handle.generation};
}
struct Round {
runtime::Transform spawn, gatePose, victoryPose;
runtime::EntityHandle gate, goal, victory;
std::array<runtime::EntityHandle, 3> tokens;
std::array<runtime::Transform, 3> tokenPoses;
std::array<bool, 3> collected{};
bool won{};
bool gateOpened{};
};
float distance(runtime::Vec3 a, runtime::Vec3 b) {
const float x = a[0] - b[0], y = a[1] - b[1], z = is3D ? a[2] - b[2] : 0;
return std::sqrt(x * x + y * y + z * z);
}
void reset(runtime::Runtime& game, runtime::EntityHandle self, Round& round) {
game.teleport(self, round.spawn);
game.setVelocity(self, {0, 0, 0});
game.teleport(round.gate, round.gatePose);
for (std::size_t i = 0; i < 3; ++i)
game.setTransform(round.tokens[i], round.tokenPoses[i]);
auto hidden = round.victoryPose;
hidden.position[1] = -50;
game.setTransform(round.victory, hidden);
round.collected = {};
round.won = false;
round.gateOpened = false;
std::cout << "New round: collect the three gold cubes, then reach the green exit. E resets.\n";
}
} // namespace
void registerGameplay(runtime::Runtime& world) {
auto rounds = std::make_shared<std::map<Key, Round>>();
runtime::Behavior collector;
collector.onStart = [rounds](runtime::Runtime& game, runtime::EntityHandle self, double) {
const auto fields = game.fields(self, collectorType);
Round round;
round.spawn = game.transform(self);
auto resolve = [&](const char* field) {
const auto handle = game.find(fields.at(field).get<std::string>());
if (!game.valid(handle))
throw std::runtime_error(std::string("Missing level reference: ") + field);
return handle;
};
round.gate = resolve("gate");
round.goal = resolve("goal");
round.victory = resolve("win_marker");
round.gatePose = game.transform(round.gate);
round.victoryPose = game.transform(round.victory);
for (std::size_t i = 0; i < 3; ++i) {
const auto field = "token_" + std::to_string(i + 1);
round.tokens[i] = resolve(field.c_str());
round.tokenPoses[i] = game.transform(round.tokens[i]);
}
auto& stored = rounds->insert_or_assign(key(self), std::move(round)).first->second;
reset(game, self, stored);
};
collector.onDestroy = [rounds](runtime::Runtime&, runtime::EntityHandle self, double) {
rounds->erase(key(self));
};
collector.fixedUpdate = [rounds](runtime::Runtime& game, runtime::EntityHandle self, double) {
auto& round = rounds->at(key(self));
const auto input = game.input();
const auto position = game.transform(self).position;
if (input.interactPressed || position[1] < -8) {
reset(game, self, round);
return;
}
const auto fields = game.fields(self, collectorType);
auto velocity = game.velocity(self);
float x = input.horizontal, z = is3D ? -input.vertical : 0;
const float length = std::sqrt(x * x + z * z);
if (length > 1) {
x /= length;
z /= length;
}
const auto speed = fields.value("speed", 4.0f);
velocity[0] = round.won ? 0 : x * speed;
if (is3D)
velocity[2] = round.won ? 0 : z * speed;
if (!round.won && input.jumpPressed && game.grounded(self))
velocity[1] = fields.value("jump_speed", 6.0f);
game.setVelocity(self, velocity);
if (round.won)
return;
for (std::size_t i = 0; i < 3; ++i)
if (!round.collected[i] && distance(position, round.tokenPoses[i].position) < 0.95f) {
round.collected[i] = true;
auto display = round.tokenPoses[i];
display.position = is3D ? runtime::Vec3{6, 2 + float(i) * 0.8f, -4}
: runtime::Vec3{-1.2f + float(i) * 1.2f, 4.5f, 0};
game.setTransform(round.tokens[i], display);
std::cout << "Collected "
<< std::count(round.collected.begin(), round.collected.end(), true)
<< "/3\n";
}
if (std::all_of(round.collected.begin(), round.collected.end(),
[](bool value) { return value; })) {
if (!round.gateOpened) {
auto open = round.gatePose;
open.position[1] = -30;
game.teleport(round.gate, open);
round.gateOpened = true;
}
if (distance(position, game.transform(round.goal).position) < 1.0f) {
round.won = true;
game.setTransform(round.victory, round.victoryPose);
std::cout << "Level complete! The gold victory marker is visible. Press E to play "
"again.\n";
}
}
};
world.registerBehavior(collectorType, std::move(collector));
}
nlohmann::json schema() {
nlohmann::json fields = {{"speed",
{{"id", "speed"},
{"name", "Move speed"},
{"type", "number"},
{"default", 4.0},
{"min", 0.0},
{"units", "m/s"}}},
{"jump_speed",
{{"id", "jump_speed"},
{"name", "Jump speed"},
{"type", "number"},
{"default", 6.0},
{"min", 0.0},
{"units", "m/s"}}}};
for (const auto* id : {"gate", "goal", "win_marker", "token_1", "token_2", "token_3"})
fields[id] = {{"id", id}, {"name", id}, {"type", "entity_ref"}, {"default", id}};
auto types = nlohmann::json::array({{{"id", collectorType},
{"name", "Collect three and escape"},
{"version", 1},
{"fields", std::move(fields)}}});
return types;
}
} // namespace faset::gameplay
@@ -0,0 +1,7 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world);
nlohmann::json schema();
} // namespace faset::gameplay
@@ -0,0 +1,8 @@
{
"format": "faset.project",
"version": 1,
"id": "example-collect-2d-project",
"name": "Collect & Escape 2D",
"dimension": 2,
"start_scene": "Scenes/main.scene.json"
}