sdl_runtime_compiler git · main
SDL3 game for running and compiling code at runtime
C++ 72.3% C 26.2%git clone https://git.christianimmanuel.de/sdl-graphics/sdl_runtime_compiler.gitwget https://git.christianimmanuel.de/sdl-graphics/sdl_runtime_compiler/archive/sdl_runtime_compiler.tar.gzsrc/text/handler.cpp raw
#include <SDL3/SDL.h>
#include <algorithm>
#include <sstream>
#include <string>
#include "entities/camera.hpp"
#include "entities/world.hpp"
#include "rendering/gl_renderer.hpp"
#include "text/handler.hpp"
#include "text/text.hpp"
#include "utils/config.hpp"
#include "wall/wall.hpp"
namespace
Nimbin
{
namespace
Text
{
namespace
{
double
editing_CharWidth()
{
static double w = 0.0;
if (w == 0.0) {
TokenPtr m1 = make_Token("M", Token::depth_wall, 0.003f, Vec3{0,0,0});
TokenPtr m2 = make_Token("MM", Token::depth_wall, 0.003f, Vec3{0,0,0});
w = 2.0 * static_cast<double>(m2->half_extent) - 2.0 * static_cast<double>(m1->half_extent);
}
return w;
}
double
editing_HalfExtentY()
{
static double h = 0.0;
if (h == 0.0) {
TokenPtr m = make_Token("M", Token::depth_wall, 0.003f, Vec3{0,0,0});
h = static_cast<double>(m->half_extent_y);
}
return h;
}
TokenPtr
make_Number(const std::string& s, const Vec3& pos, float yaw, float scale)
{
TokenPtr t = make_Token(s, Token::depth_wall, scale, pos, Config::Color::orange);
t->yaw = yaw;
t->set_Color(Config::Color::orange);
t->set_ColorSource(ColorSource::Static);
return t;
}
void
set_NumberText(Token::Data* t, const std::string& s)
{
if (t->text == s) return;
t->retext(s);
t->set_Color (Config::Color::code_keyword);
t->set_ColorSource(ColorSource::Static);
}
} // namespace
Handler::Handler(Wall::Role role, float world_scale, LayoutMode mode)
: _role(role), _layout(mode)
{
_transform = Wall::get_Transform(role, world_scale);
_line_height = Config::Wall::line_height;
_word_spacing = Config::Wall::free_space_dist;
_append_color = Config::Color::dark_gray;
// The editor is always-Editing: it keeps a persistent buffer (its source of
// truth) even when not connected, and lays out on the editing grid at rest.
// One relayout here establishes _char_w before the first thrown word.
if (_layout == LayoutMode::Editing) {
_vim = cpputils::make_unique<VimBuffer>();
_eye_y = World::get_FloorY<double>() + 2.0;
relayout_Editing(); // establishes _char_w; _face keeps its Pitch default (tilt at rest)
}
}
void
Handler::set_Layout(LayoutMode m)
{
if (m == _layout) return;
_layout = m;
relayout();
}
void
Handler::set_ShowNumbers(bool on, bool both_columns)
{
_show_numbers = on;
_both_columns = both_columns;
}
void
Handler::set_DefaultStyle(float depth, float scale, double line_height, double word_spacing)
{
_depth = depth;
_scale = scale;
_line_height = line_height;
_word_spacing = word_spacing;
}
void Handler::clear()
{
_lines .clear();
_last_src .clear();
_label_off .clear();
_label_text.clear();
_view_tag = 0;
_borrowed = nullptr; _view_tag = 0;
mark_Dirty();
}
void
Handler::set_Lines(const DynArray<std::string>& lines)
{
_lines.clear();
const ColorSource src = _lex_enabled ? ColorSource::Lexed : ColorSource::Static;
for (const std::string& text : lines) {
Line ln;
ln.line_height = _line_height;
ln.word_spacing = _word_spacing;
size_t byte = 0;
while (byte < text.size()) {
std::string tk;
const char c = text[byte];
if (c == ' ') { tk = " "; ++byte; }
else if (c == '\t') { tk = "\t"; ++byte; }
else { while (byte < text.size() && text[byte] != ' ' && text[byte] != '\t') { tk += text[byte]; ++byte; } }
TokenPtr t = make_Token(tk, _depth, _scale, _transform.position, Config::Color::wall);
t->yaw = _transform.yaw;
t->set_ColorSource(src);
ln.tokens.push_back(std::move(t));
}
_lines.push_back(std::move(ln));
}
// Editor (persistent buffer): make the buffer the authority for the editing
// relayout, so loaded content shows as columned code and round-trips.
if (_vim) {
std::vector<std::string> v;
for (const std::string& s : lines) v.push_back(s);
_vim->load(v);
_vim_seen_version = _vim->get_Version();
}
relayout();
mark_Dirty();
}
void
Handler::append_Line(const std::string& text)
{
//SPIKE("append_Line.detail");
for (Line& l : _lines) {
l.y += _line_height;
for (TokenPtr& t : l.tokens) if (t) t->pos.y += _line_height;
}
Line ln;
ln.y = _append_y;
TokenPtr t = make_Token(text, _depth, _scale, _transform.position, _append_color);
t->yaw = _transform.yaw;
t->pos = _transform.position;
t->pos.y = _append_y;
t->set_Color(_append_color);
ln.tokens.push_back(std::move(t));
_lines.push_back(std::move(ln));
mark_Dirty();
}
void
Handler::append_Tokens(DynArray<TokenPtr> block)
{
DynArray<TokenPtr> toks;
toks.reserve(block.size());
for (TokenPtr& t : block) if (t) toks.push_back(std::move(t));
if (toks.empty()) return;
for (Line& l : _lines) {
l.y += _line_height;
for (TokenPtr& t : l.tokens) if (t) t->pos.y += _line_height;
}
double row_w = 0.0;
for (size_t i = 0; i < toks.size(); i++) {
row_w += 2.0 * (double)toks[i]->half_extent;
if (i + 1 < toks.size()) row_w += _word_spacing;
}
Line ln;
ln.y = _append_y;
ln.line_height = _line_height;
double cursor = row_w * 0.5;
for (TokenPtr& t : toks) {
const double hw = (double)t->half_extent;
t->yaw = _transform.yaw;
t->pos = _transform.position;
t->pos.y = _append_y;
t->pos.z = cursor - hw;
t->body.mode = PhysicsMode::Static;
t->body.vel = Vec3{0, 0, 0};
t->set_ColorSource(ColorSource::Static);
t->set_Color(_append_color);
cursor = t->pos.z - hw - _word_spacing;
ln.tokens.push_back(std::move(t));
}
_lines.push_back(std::move(ln));
mark_Dirty();
}
TokenPtr
Handler::detach(Token::Data* t)
{
if (edit_Active()) return nullptr;
for (size_t line_idx = 0; line_idx < _lines.size(); ++line_idx) {
Line& l = _lines[line_idx];
for (size_t i = 0; i < l.tokens.size(); i++) {
if (l.tokens[i].get() == t) {
TokenPtr out = std::move(l.tokens[i]);
l.tokens.erase(l.tokens.begin() + static_cast<std::ptrdiff_t>(i));
if (l.tokens.empty())
_lines.erase(_lines.begin() + static_cast<std::ptrdiff_t>(line_idx));
mark_Dirty();
if (_vim) { sync_VimFromLines(); relayout_Editing(); } // keep buffer in step
return out;
}
}
}
return nullptr;
}
void
Handler::attach(DynArray<TokenPtr> tokens, const Vec3& impact)
{
if (tokens.empty()) return;
if (_layout == LayoutMode::Editing)
attach_Editing (tokens, impact);
else attach_Display(tokens, impact);
mark_Dirty();
}
void
Handler::clear_ToFloor(DynArray<TokenPtr>& out)
{
const double hover = World::get_FloorY<double>() + 1.5;
for (Line& l : _lines) {
for (TokenPtr& t : l.tokens) {
if (!t) continue;
t->body.mode = PhysicsMode::Dynamic;
t->body.vel = { 0.0, 1.5, 0.0 };
t->body.hover_height = hover;
t->set_Color (Config::Color::floor);
t->set_Depth (Token::depth_floor);
t->set_ColorSource (ColorSource::Lint);
out.push_back (std::move(t));
}
}
_lines.clear();
if (_vim) { // editor: clear the buffer too, else relayout resurrects
_vim->load(std::vector<std::string>{});
_vim_seen_version = _vim->get_Version();
}
mark_Dirty();
}
Handler::Target
Handler::resolve_Target(double y) const
{
DynArray<size_t> order;
for (size_t i = 0; i < _lines.size(); i++)
if (!_lines[i].is_Empty()) order.push_back(i);
if (order.empty())
return { Target::Insert, 0, y, 0.0 };
std::sort(order.begin(), order.end(),
[&](size_t a, size_t b){ return _lines[a].y > _lines[b].y; });
auto glyph_top = [&](size_t line_idx) {
return _lines[line_idx].y
+ (double)_lines[line_idx].tokens.front()->half_extent_y
+ Config::Wall::merge_top_margin;
};
auto underline = [&](size_t line_idx) { return _lines[line_idx].y - Config::Wall::underline_drop; };
// 1) Merge: within a line's glyph band.
for (size_t line_idx : order)
if (y <= glyph_top(line_idx) && y >= underline(line_idx))
return { Target::Merge, line_idx, 0.0, 0.0 };
const double LH = Config::Wall::line_height;
// 2) Above the top line.
{
const size_t top = order.front();
if (y > glyph_top(top)) {
const double min_y = _lines[top].y + LH;
const double new_y = (y > min_y) ? y : min_y;
return { Target::Insert, top, new_y, 0.0 };
}
}
// 3) Between two lines.
for (size_t oi = 0; oi + 1 < order.size(); ++oi) {
const size_t hi = order[oi];
const size_t lo = order[oi + 1];
if (y < underline(hi) && y > glyph_top(lo)) {
const double hi_y = _lines[hi].y, lo_y = _lines[lo].y;
const double gap = hi_y - lo_y;
const double free_top = hi_y - LH;
const double free_bot = lo_y + LH;
if (gap >= 2.0 * LH && y <= free_top && y >= free_bot)
return { Target::Insert, lo, y, 0.0 };
if (gap >= 2.0 * LH)
return { Target::Insert, lo, (y > free_top) ? free_top : free_bot, 0.0 };
const double deficit = 2.0 * LH - gap;
return { Target::Insert, lo, free_top, deficit };
}
}
// 4) Below the bottom line.
{
const size_t bot = order.back();
const double max_y = _lines[bot].y - LH;
const double new_y = (y < max_y) ? y : max_y;
return { Target::Insert, bot, new_y, 0.0 };
}
}
Line&
Handler::apply_Target(const Target& t)
{
if (t.kind == Target::Merge)
return _lines[t.line_index];
if (t.push_dist > 0.0 && t.line_index < _lines.size()) {
const double push_below_y = _lines[t.line_index].y;
for (Line& l : _lines) {
if (l.y <= push_below_y + 1e-6) {
l.y -= t.push_dist;
for (TokenPtr& w : l.tokens) if (w) w->pos.y -= t.push_dist;
if (l.num_left) l.num_left ->pos.y -= t.push_dist;
if (l.num_right) l.num_right->pos.y -= t.push_dist;
}
}
}
Line fresh;
fresh.y = t.new_y;
fresh.line_height = _line_height;
size_t pos = 0;
while (pos < _lines.size() && _lines[pos].y > fresh.y) ++pos;
auto it = _lines.insert(_lines.begin() + static_cast<std::ptrdiff_t>(pos),
std::move(fresh));
return *it;
}
// Materialise whitespace tokens between adjacent words so the line round-trips
// through as_Lines (raw concat) without merging. Space tokens are invisible on
// the wall (Handler::render skips whitespace), so only array ORDER matters here
// — their wall position is irrelevant; relayout_Editing repositions them on edit.
void
Handler::ensure_WordSeparators(Line& l)
{
for (size_t i = 0; i + 1 < l.tokens.size(); ) {
const TokenPtr& a = l.tokens[i];
const TokenPtr& b = l.tokens[i + 1];
if (a && b && !a->is_Whitespace() && !b->is_Whitespace()) {
TokenPtr sp = make_Token(" ", _depth, _scale,
_transform.position, Config::Color::wall);
sp->yaw = _transform.yaw;
sp->pos = a->pos; // invisible; sits by its left neighbour
sp->set_ColorSource(_lex_enabled ? ColorSource::Lexed : ColorSource::Static);
l.tokens.insert(l.tokens.begin() + static_cast<std::ptrdiff_t>(i + 1),
std::move(sp));
i += 2; // step past the word and the new space
} else {
++i;
}
}
}
void
Handler::attach_Display(DynArray<TokenPtr>& block, const Vec3& impact)
{
std::sort(block.begin(), block.end(),
[](const TokenPtr& a, const TokenPtr& b){
if (!a) return false;
if (!b) return true;
return a->pos.z > b->pos.z;
});
for (TokenPtr& w : block) {
if (!w) continue;
w->yaw = _transform.yaw;
w->set_ColorSource(_lex_enabled ? ColorSource::Lexed : ColorSource::Static);
w->set_Depth(_depth);
}
double block_w = 0.0;
size_t live = 0;
for (const TokenPtr& w : block) {
if (!w) continue;
if (live) block_w += Config::Wall::free_space_dist;
block_w += 2.0 * (double)w->half_extent;
++live;
}
if (live == 0) return;
Target t = resolve_Target(impact.y);
Line& target = apply_Target(t);
auto left_edge = [](const Token::Data* w){ return w->pos.z + (double)w->half_extent; };
auto right_edge = [](const Token::Data* w){ return w->pos.z - (double)w->half_extent; };
size_t insert_index = 0;
for (const TokenPtr& w : target.tokens)
if (w && w->pos.z > impact.z) ++insert_index;
const size_t n = target.tokens.size();
double block_left_z = impact.z + block_w * 0.5;
if (insert_index > 0) {
const double max_left = right_edge(target.tokens[insert_index - 1].get())
- Config::Wall::free_space_dist;
if (block_left_z > max_left) block_left_z = max_left;
}
if (insert_index < n) {
const double block_right = block_left_z - block_w;
const double min_right = left_edge(target.tokens[insert_index].get())
+ Config::Wall::free_space_dist;
if (block_right < min_right) {
const double shift = min_right - block_right;
for (size_t k = insert_index; k < n; ++k)
target.tokens[k]->pos.z -= shift;
}
}
// Lay the run out left->right (decreasing z), snap to the row height.
double cursor = block_left_z;
for (TokenPtr& w : block) {
if (!w) continue;
const double hw = (double)w->half_extent;
const double center_z = cursor - hw;
w->pos.y = target.y;
w->pos.z = center_z;
w->pos.x = _transform.position.x;
cursor = center_z - hw - Config::Wall::free_space_dist;
}
// Insert into the line at the chosen slot, in visual order, taking ownership.
size_t k = 0;
for (TokenPtr& w : block) {
if (!w) continue;
target.tokens.insert(
target.tokens.begin() + static_cast<std::ptrdiff_t>(insert_index + k),
std::move(w));
++k;
}
block.clear();
// Words just landed with no whitespace between them — materialise separator
// tokens so begin_Edit / as_Lines won't merge them (e.g. "int" "x" → "intx").
ensure_WordSeparators(target);
}
bool
Handler::impact_To_RowCol(const Vec3& impact, int& row, int& col) const
{
if (!_vim || _char_w <= 0.0) return false;
const auto& blines = _vim->get_Lines();
const int n = static_cast<int>(blines.size());
if (n == 0) return false;
int r = static_cast<int>(SDL_lround((_row0_y - impact.y) / _line_height));
r = SDL_clamp(r, 0, n);
if (r == n) { row = n; col = 0; return true; }
const std::string& line = blines[static_cast<size_t>(r)];
int c = static_cast<int>(SDL_lround((_code_left_z - impact.z) / _char_w - 0.5));
if (c < 0) c = 0;
const int len = (int)line.size();
if (c >= len) { row = r; col = c; return true; }
c = SDL_clamp(c, 0, len);
auto is_gap = [&](int i) {
return i == 0 || i == (int)line.size()
|| line[(size_t)i] == ' ' || line[(size_t)(i - 1)] == ' ';
};
if (!is_gap(c)) {
int lo = c, hi = c;
while (lo > 0 && !is_gap(lo)) --lo;
while (hi < (int)line.size() && !is_gap(hi)) ++hi;
c = (c - lo <= hi - c) ? lo : hi;
}
row = r; col = c;
return true;
}
Handler::EditorHit
Handler::classify_Impact(const Vec3& impact) const
{
EditorHit hit{ HitKind::NewLine, 0, 0, 0, 0 };
if (!_vim || _char_w <= 0.0) return hit; // empty/unmeasured → seed line 0
const auto& blines = _vim->get_Lines();
const int N = static_cast<int>(blines.size());
const double lh = _line_height;
auto col_From_Z = [&]() -> int {
int c = static_cast<int>(SDL_lround((_code_left_z - impact.z) / _char_w));
return c < 0 ? 0 : c;
};
if (N == 0) { hit.kind = HitKind::NewLine; hit.row = 0; hit.col = col_From_Z(); return hit; }
// ── vertical: a row is "owned" only within the glyph cap height (≈ an 'A'/'M',
// ±_he_y about the row centre) — NOT the whole line height. Aiming in the gap
// between two rows inserts a new line there; aiming above the first row or
// below the last appends/prepends. This mirrors the uniform cell box used for
// the horizontal hit, so "clearly above/below a token" makes a new line
// instead of dropping it, while the inter-row gap can no longer masquerade as
// a hit on the nearest token. ──
const double rf = (_row0_y - impact.y) / lh;
const int i = static_cast<int>(SDL_lround(rf));
if (i < 0) { hit.kind = HitKind::NewLine; hit.row = i; hit.col = col_From_Z(); return hit; } // prepend
if (i >= N) { hit.kind = HitKind::NewLine; hit.row = i; hit.col = col_From_Z(); return hit; } // append
const double row_y = _row0_y - static_cast<double>(i) * lh;
if (SDL_fabs(impact.y - row_y) > _he_y) { // in the inter-row gap → new line
hit.kind = HitKind::NewLine;
hit.row = (impact.y < row_y) ? i + 1 : i; // below this row → line after it
hit.col = col_From_Z();
return hit;
}
// ── horizontal on row i: re-chunk the buffer line and apply 90/10 per token span ──
hit.row = i; hit.line_idx = static_cast<size_t>(i);
const std::string& s = blines[static_cast<size_t>(i)];
const double vcf = (_code_left_z - impact.z) / _char_w; // fractional visual column
int vcol = 0; size_t b = 0; size_t k = 0;
// Split exactly like relayout_Editing: each space/tab is one cell, and a
// non-whitespace run is lexically split into word / operator-group /
// single-punctuation sub-tokens. This keeps `k` in lock-step with
// line.tokens, so a DropToken's token_idx points at the token actually
// aimed at — previously a run was one coarse cell, so token_idx skewed and
// the caller detached the wrong token (often whitespace, esp. in sandbox).
auto is_word = [](char cc) {
return (cc >= 'a' && cc <= 'z') || (cc >= 'A' && cc <= 'Z')
|| (cc >= '0' && cc <= '9') || cc == '_';
};
auto is_op = [](char cc) {
for (const char* o = "+-*/%=<>!&|^~.:?"; *o; ++o) if (*o == cc) return true;
return false;
};
while (b < s.size()) {
const char c = s[b];
int b0 = static_cast<int>(b), b1, vc = vcol, vs;
bool ws;
if (c == ' ') { b1 = b0 + 1; vs = 1; ws = true; ++b; }
else if (c == '\t') { b1 = b0 + 1; vs = 4 - (vcol%4); ws = true; ++b; }
else {
const size_t s0 = b;
if (is_word(c)) { while (b < s.size() && is_word(s[b])) ++b; }
else if (is_op(c)) { while (b < s.size() && is_op(s[b])) ++b; }
else { ++b; } // single bracket / ; , quote …
b1 = static_cast<int>(b); vs = static_cast<int>(b - s0); ws = false;
}
if (vcf >= vc && vcf < vc + vs) {
if (ws) { hit.kind = HitKind::Insert; hit.col = b1; return hit; } // split: word after the space
const double center = vc + vs * 0.5;
if (SDL_fabs(vcf - center) <= 0.45 * vs) { // central 90% → drop
hit.kind = HitKind::DropToken; hit.line_idx = static_cast<size_t>(i); hit.token_idx = k;
return hit;
}
hit.kind = HitKind::Insert; // outer 5% each side → beside
hit.col = (vcf < center) ? b0 : b1;
return hit;
}
vcol += vs; ++k;
}
// before the first column or past the end of the line
hit.kind = HitKind::Insert;
hit.col = (vcf < 0.0) ? 0 : static_cast<int>(s.size());
return hit;
}
void
Handler::attach_Editing(DynArray<TokenPtr>& block, const Vec3& impact)
{
if (!_vim) { block.clear(); return; }
const EditorHit hit = classify_Impact(impact);
std::string word;
const Token::Data* prev = nullptr;
for (const TokenPtr& w : block) {
if (!w) continue;
if (prev && !prev->is_Whitespace() && !w->is_Whitespace())
word += ' ';
word += w->text;
prev = w.get();
}
block.clear();
if (word.empty()) return;
// attach only inserts; the DropToken outcome is handled by the caller.
_vim->insert_Word_At(hit.row, hit.col, word, /*new_line=*/hit.kind == HitKind::NewLine);
_vim_seen_version = _vim->get_Version();
relayout_Editing();
}
void
Handler::mark_Dirty()
{
++_gen;
_dirty = true;
}
std::string
Handler::render_Source()
{
std::string out;
for (Line& line : _lines) {
for (size_t i = 0; i < line.tokens.size(); i++) {
Token::Data* t = line.tokens[i].get();
if (!t) continue;
t->byte_begin = out.size();
out += t->text;
t->byte_end = out.size();
}
out += '\n';
}
_last_src = out;
_dirty = false;
return out;
}
void
Handler::apply_Highlight(const DynArray<HiSpan>& spans, uint64_t gen)
{
if (gen != _gen) return;
for (Line& line : _lines) {
for (TokenPtr& tp : line.tokens) {
Token::Data* t = tp.get();
if (!t || t->color_src != ColorSource::Lexed) continue;
size_t best = 0;
Token::Type type = Token::Type::Identifier;
bool found = false;
for (const HiSpan& s : spans) {
size_t lo = (t->byte_begin > s.byte_begin) ? t->byte_begin : s.byte_begin;
size_t hi = (t->byte_end < s.byte_end) ? t->byte_end : s.byte_end;
if (lo < hi) {
size_t ov = hi - lo;
if (ov > best) { best = ov; type = s.type; found = true; }
}
}
if (found) t->set_Context(Token::get_Color(type));
}
}
}
Token::Data*
Handler::token_At_Byte(size_t off)
{
for (Line& line : _lines)
for (TokenPtr& t : line.tokens)
if (t && off >= t->byte_begin && off < t->byte_end)
return t.get();
return nullptr;
}
DynArray<std::string>
Handler::as_Lines() const
{
DynArray<std::string> out;
out.reserve(_lines.size());
for (const Line& line : _lines) {
std::string s;
for (const TokenPtr& t : line.tokens) if (t) s += t->text;
out.push_back(std::move(s));
}
return out;
}
void
Handler::relayout()
{
if (_layout == LayoutMode::Editing) relayout_Editing();
else if (_layout == LayoutMode::FaceCamera) relayout_FaceCamera();
else relayout_Display();
}
void
Handler::relayout_Display()
{
const double x_wall = _transform.position.x;
const double lh = _line_height;
const double gap = 0.5;
const double floor_y = World::get_FloorY<double>();
size_t n_lines = 0;
for (const Line& l : _lines) if (!l.is_Empty()) ++n_lines;
if (n_lines == 0) return;
const double bottom_y = floor_y + 1.2;
double y = bottom_y + static_cast<double>(n_lines - 1) * lh;
double max_width = 0.0;
for (Line& l : _lines) {
if (l.is_Empty()) continue;
double total_w = 0.0;
for (TokenPtr& t : l.tokens) {
t->yaw = _transform.yaw;
total_w += 2.0 * (double)t->half_extent;
}
total_w += gap * static_cast<double>(l.tokens.size() - 1);
max_width = SDL_max(max_width, total_w);
l.y = y;
y -= lh;
}
const double left_z = max_width * 0.5;
for (Line& l : _lines) {
if (l.is_Empty()) continue;
double cursor = left_z;
for (TokenPtr& t : l.tokens) {
const double half = (double)t->half_extent;
const double center_z = cursor - half;
t->pos = Vec3{ x_wall, l.y, center_z };
cursor = center_z - half - gap;
}
}
}
void
Handler::relayout_Editing()
{
if (!_vim) return;
const auto& blines = _vim->get_Lines();
const double lh = _line_height;
const int R = _vim->get_Row();
const int N = static_cast<int>(blines.size());
const double ws = World::get_BoxScale();
const double bottom_y = -ws + 1.2;
const double anchor_bottom = bottom_y + static_cast<double>(N - 1) * lh;
if (_capturing) {
// vim-style scrolloff, zoom-aware: rows_half = how many rows fit between
// the eye and the top edge at the current zoom; MARGIN can't exceed it, so
// the first/last line stays in view (gg no longer loses line 1 off the top
// when zoomed in). 0 → unknown → fall back to the cap.
constexpr int MARGIN_MAX = 4; // cap when zoomed out (vim scrolloff feel)
constexpr int PAD = 1; // blank rows kept at the file ends
const int rows_half = (_view_half_h > 0.0)
? static_cast<int>(SDL_floor(_view_half_h / lh))
: MARGIN_MAX;
const int MARGIN = SDL_clamp(rows_half - PAD, 1, MARGIN_MAX);
const int visible = 2 * MARGIN + 1;
_scroll_top = SDL_clamp(R - MARGIN, -PAD, SDL_max(0, N - visible) + PAD);
_row0_y = (_eye_y + static_cast<double>(MARGIN) * lh)
+ static_cast<double>(_scroll_top) * lh;
}
else {
_row0_y = anchor_bottom; // at rest: whole file bottom-aligned to the floor
}
_char_w = editing_CharWidth();
_he_y = editing_HalfExtentY();
// Salvage old rows so unchanged lines keep their meshes (reuse only when the
// line COUNT is unchanged — matches the original per-keystroke fast path).
DynArray<Line> old = std::move(_lines);
_lines.clear();
const bool can_reuse = (old.size() == blines.size());
DynArray<std::string> old_text(old.size());
for (size_t i = 0; i < old.size(); i++) {
std::string s;
for (size_t j = 0; j < old[i].tokens.size(); ++j) {
if (old[i].tokens[j]) s += old[i].tokens[j]->text;
}
old_text[i] = std::move(s);
}
// Pass 1: build tokens + gutter numbers; measure widest digit + longest line.
struct BT { TokenPtr t; int vcol; int vspan; };
struct BR { DynArray<BT> toks; TokenPtr num_l; TokenPtr num_r; };
DynArray<BR> built;
built.reserve(blines.size());
double gutter_w = 0.0, block_code_w = 0.0;
for (size_t i = 0; i < blines.size(); i++) {
const std::string& s = blines[i];
block_code_w = SDL_max(block_code_w, _char_w * static_cast<double>(s.size()));
BR br;
{
const std::string s_no = std::to_string(i + 1);
if (can_reuse && i < old.size() && old[i].num_left)
br.num_l = std::move(old[i].num_left);
else
br.num_l = make_Number(s_no, _transform.position, _transform.yaw, 0.0022f);
if (_both_columns) {
if (can_reuse && i < old.size() && old[i].num_right)
br.num_r = std::move(old[i].num_right);
else
br.num_r = make_Number(s_no, _transform.position, _transform.yaw, 0.0022f);
}
if (br.num_l) {
set_NumberText(br.num_l.get(), s_no);
gutter_w = SDL_max(gutter_w, 2.0 * (double)br.num_l->half_extent);
}
if (br.num_r) set_NumberText(br.num_r.get(), s_no);
}
const bool reuse_line = can_reuse && i < old.size() && old_text[i] == s;
struct Chunk { std::string text; int vcol; int vspan; };
DynArray<Chunk> chunks;
{ int vcol = 0; size_t byte = 0;
while (byte < s.size()) {
const char c = s[byte];
if (c == ' ') { chunks.push_back({ " ", vcol, 1 }); vcol += 1; ++byte; }
else if (c == '\t') { int span = 4 - (vcol % 4);
chunks.push_back({ "\t", vcol, span }); vcol += span; ++byte; }
else {
// Lexically split the run so each identifier/number, operator group,
// and punctuation char becomes its own token with its own colour.
// (Previously the whole run was one token, so "a*2;", foo(int, or
// ist"; coloured as a single unit / bled the neighbour's colour.)
auto is_word = [](char cc) {
return (cc >= 'a' && cc <= 'z') || (cc >= 'A' && cc <= 'Z')
|| (cc >= '0' && cc <= '9') || cc == '_';
};
auto is_op = [](char cc) {
for (const char* o = "+-*/%=<>!&|^~.:?"; *o; ++o) if (*o == cc) return true;
return false;
};
std::string tk;
const char c0 = s[byte];
if (is_word(c0)) { while (byte < s.size() && is_word(s[byte])) { tk += s[byte]; ++byte; } }
else if (is_op(c0)) { while (byte < s.size() && is_op(s[byte])) { tk += s[byte]; ++byte; } }
else { tk += c0; ++byte; } // single bracket / ; , quote …
chunks.push_back({ tk, vcol, (int)tk.size() }); vcol += (int)tk.size();
}
}
block_code_w = SDL_max(block_code_w, _char_w * (double)vcol); // ← visual width, not s.size()
}
size_t token_idx = 0;
for (const Chunk& ch : chunks) {
TokenPtr t;
if (reuse_line && token_idx < old[i].tokens.size()) {
t = std::move(old[i].tokens[token_idx]);
if (t && t->text != ch.text) t->retext(ch.text); // robust if the split shifts
}
else {
t = make_Token(ch.text, Token::depth_wall, 0.003f, _transform.position, Config::Color::white);
t->yaw = _transform.yaw;
t->set_ColorSource(ColorSource::Lexed);
}
br.toks.push_back({ std::move(t), ch.vcol, ch.vspan });
++token_idx;
}
built.push_back(std::move(br));
}
const double gutter_gap = 0.6;
const double code_u0 = gutter_w + gutter_gap;
const double right_gutter = _both_columns ? (gutter_gap + gutter_w) : 0.0;
const double block_w = code_u0 + block_code_w + right_gutter;
const double z_block_left = block_w * 0.5;
_code_left_z = z_block_left - code_u0;
// Pass 2: position numbers + tokens on the grid.
for (size_t i = 0; i < built.size(); i++) {
const double row_y = _row0_y - static_cast<double>(i) * lh;
Line ln;
ln.y = row_y;
ln.line_height = lh;
ln.number = static_cast<int>(i) + 1;
ln.show_number = true;
if (built[i].num_l) {
const double he = (double)built[i].num_l->half_extent;
built[i].num_l->pos = _transform.position;
built[i].num_l->pos.y = row_y;
built[i].num_l->pos.z = z_block_left - he;
ln.num_left = std::move(built[i].num_l);
}
if (built[i].num_r) {
const double he = (double)built[i].num_r->half_extent;
built[i].num_r->pos = _transform.position;
built[i].num_r->pos.y = row_y;
built[i].num_r->pos.z = (z_block_left - block_w) + he; // just inside right edge
ln.num_right = std::move(built[i].num_r);
}
for (BT& bt : built[i].toks) {
const double left_z = _code_left_z - (double)bt.vcol * _char_w;
const double tok_w = _char_w * (double)bt.vspan;
bt.t->pos = _transform.position;
bt.t->pos.y = row_y;
bt.t->pos.z = left_z - tok_w * 0.5;
// Uniform editingspace cell for collision/aim: EVERY token (not just
// whitespace) gets a cell-width × cap-height box. The projectile
// intercept tests shape.half_extents (via aabb_Min/Max), so any token
// keeping its ink extents ("," tiny, "M" wide, "g" tall with descender)
// made the aim AABBs drift off the visible editingspace columns and rows.
// Width = one cell; height = the cap height of an 'A'/'M' (_he_y), the
// same metric classify_Impact uses for its vertical row ownership.
bt.t->half_extent = (float)(tok_w * 0.5);
bt.t->half_extent_y = (float)_he_y;
bt.t->shape.half_extents.x = tok_w * 0.5;
bt.t->shape.half_extents.y = _he_y;
// Embedded in the wall now → not "loose in the world", so no bloom.
// Whitespace tokens are constructed with glow on (a glowing ghost while
// carried / in flight); clear it once they're placed so the editor wall
// doesn't bloom the indentation. Re-enabled naturally on the next loose
// token; placed tokens stay calm.
bt.t->glow_enabled = false;
ln.tokens.push_back(std::move(bt.t));
}
_lines.push_back(std::move(ln));
}
}
void
Handler::maintain_Numbers(double dt, const Camera&)
{
if (_lines.empty()) return;
DynArray<size_t> order;
order.reserve(_lines.size());
for (size_t i = 0; i < _lines.size(); i++)
if (!_lines[i].is_Empty()) order.push_back(i);
if (order.empty()) return;
std::sort(order.begin(), order.end(),
[&](size_t a, size_t b){ return _lines[a].y > _lines[b].y; });
// Global code extents in z, so both columns align with a uniform gap.
double code_left = -1e18, code_right = 1e18;
for (size_t oi : order)
for (TokenPtr& w : _lines[oi].tokens) {
if (!w) continue;
code_left = SDL_max(code_left, w->pos.z + (double)w->half_extent);
code_right = SDL_min(code_right, w->pos.z - (double)w->half_extent);
}
double max_lh = 0.0, max_rh = 0.0;
int rank = 1;
for (size_t oi : order) {
Line& l = _lines[oi];
const std::string s = std::to_string(rank);
if (!l.num_left)
l.num_left = make_Number(s, Vec3{_transform.position.x, l.y, 0.0}, _transform.yaw,
Config::Wall::line_number_scale);
if (_both_columns && !l.num_right)
l.num_right = make_Number(s, Vec3{_transform.position.x, l.y, 0.0}, _transform.yaw,
Config::Wall::line_number_scale);
if (l.number != rank) {
set_NumberText(l.num_left.get(), s);
if (l.num_right) set_NumberText(l.num_right.get(), s);
l.number = rank;
}
constexpr double STIFFNESS = 120.0, DAMPING = 12.0;
l.bounce_vel += (-STIFFNESS * l.bounce_off - DAMPING * l.bounce_vel) * dt;
l.bounce_off += l.bounce_vel * dt;
max_lh = SDL_max(max_lh, (double)l.num_left->half_extent);
if (l.num_right) max_rh = SDL_max(max_rh, (double)l.num_right->half_extent);
++rank;
}
const double ws = World::get_BoxScale();
const double EDGE = 0.3;
double left_col_z = code_left + Config::Wall::line_number_gap;
double right_col_z = code_right - Config::Wall::line_number_gap;
left_col_z = SDL_min(left_col_z, ws - EDGE - 2.0 * max_lh);
right_col_z = SDL_max(right_col_z, -ws + EDGE + 2.0 * max_rh);
for (size_t oi : order) {
Line& l = _lines[oi];
double x = _transform.position.x;
for (TokenPtr& w : l.tokens) { if (w) { x = w->pos.x; break; } }
const double ny = l.y + l.bounce_off;
l.num_left->pos = Vec3{ x, ny, left_col_z + (double)l.num_left->half_extent };
if (l.num_right)
l.num_right->pos = Vec3{ x, ny, right_col_z - (double)l.num_right->half_extent };
}
}
void
Handler::apply_Tilt(const Camera& cam)
{
if (_face == FaceMode::None) return;
// Phase 1 implements the wall case (Pitch toward eye). Yaw/Both (carried,
// facecam labels) arrive with the TextLabel fold-in.
if (_face != FaceMode::Pitch) return;
auto tilt = [&](Token::Data* t) {
if (t) t->pitch = Wall::tilt_TwardEye(t->pos, cam.pos);
};
for (Line& l : _lines) {
for (TokenPtr& t : l.tokens) tilt(t.get());
tilt(l.num_left.get());
tilt(l.num_right.get());
}
}
void
Handler::update(double dt, const Camera& cam)
{
if (_layout == LayoutMode::FaceCamera) { facecam_Update(dt, cam); return; }
if (_layout == LayoutMode::Display && _show_numbers)
maintain_Numbers(dt, cam);
apply_Tilt(cam);
}
void
Handler::render(GlRenderer& gl, const Camera& cam, const Light& light) const
{
for (const Line& l : _lines) {
for (const TokenPtr& t : l.tokens)
if (t && !t->is_Whitespace()) t->draw(gl, cam, light);
if (_show_numbers) {
if (l.num_left) l.num_left ->draw(gl, cam, light);
if (l.num_right) l.num_right->draw(gl, cam, light);
}
}
if (_borrowed)
for (const TokenPtr& t : *_borrowed)
if (t) t->draw(gl, cam, light);
}
void
Handler::scroll(double delta)
{
for (Line& l : _lines) {
l.y += delta;
for (TokenPtr& t : l.tokens) if (t) t->pos.y += delta;
if (l.num_left) l.num_left ->pos.y += delta;
if (l.num_right) l.num_right->pos.y += delta;
}
}
DynArray<Handler::NumberHit>
Handler::number_Hitboxes() const
{
constexpr double PAD = 0.30;
constexpr double DEPTH_X = 0.50;
DynArray<NumberHit> out;
out.reserve(_lines.size() * 2);
auto push_box = [&](const Token::Data* t, size_t i) {
const double hz = (double)t->half_extent + PAD;
const double hy = (double)t->half_extent_y + PAD;
const Vec3 c = t->pos;
out.push_back({ HIT_BASE + i,
Vec3{ c.x - DEPTH_X, c.y - hy, c.z - hz },
Vec3{ c.x + DEPTH_X, c.y + hy, c.z + hz } });
};
for (size_t i = 0; i < _lines.size(); i++) {
if (_lines[i].num_left) push_box(_lines[i].num_left.get(), i);
if (_lines[i].num_right) push_box(_lines[i].num_right.get(), i);
}
return out;
}
double
Handler::scroll_DeltaToEye(size_t line_idx, double eye_y) const
{
if (line_idx >= _lines.size()) return 0.0;
return eye_y - _lines[line_idx].y;
}
void
Handler::bounce(size_t line_idx)
{
if (line_idx < _lines.size())
_lines[line_idx].bounce_vel += Config::Wall::bounce_impulse;
}
Handler::EditingMetrics
Handler::editing_Metrics() const
{
return { _char_w, _he_y, _code_left_z, _row0_y };
}
double
Handler::row_Y(int row) const
{
return _row0_y - static_cast<double>(row) * _line_height;
}
void
Handler::begin_Edit(double eye_y)
{
if (!_vim) _vim = cpputils::make_unique<VimBuffer>(); // editor persists one; guard anyway
std::vector<std::string> v;
for (const std::string& s : as_Lines()) v.push_back(s);
_vim->load(v);
if (_vim->is_Empty()) _vim->start_Insert();
_vim_seen_version = _vim->get_Version();
_capturing = true;
_eye_y = eye_y;
_status.clear();
_layout = LayoutMode::Editing;
relayout_Editing();
mark_Dirty();
}
void
Handler::end_Edit()
{
_capturing = false;
_face = FaceMode::Pitch; // tilt toward the eye again at rest (flat only while editing)
// Stay on the editing grid; the buffer persists as the source of truth. Recolor
// to the lexed scheme (the connected view may have shown edit-time colours).
for (Line& l : _lines)
for (TokenPtr& t : l.tokens)
if (t) t->set_ColorSource(ColorSource::Lexed);
relayout_Editing();
mark_Dirty();
}
void
Handler::sync_VimFromLines()
{
if (!_vim) return;
std::vector<std::string> v;
for (const std::string& s : as_Lines()) v.push_back(s);
_vim->load(v);
_vim_seen_version = _vim->get_Version();
}
Handler::EditReq
Handler::feed_Keys(DynArray<int>& keys, double eye_y)
{
_eye_y = eye_y;
if (!_vim) return EditReq::None;
const int row_before = _vim->get_Row(); // detect motion-only changes (j/k/gg/G…)
for (int k : keys) {
if (k == -1) _vim->key_enter();
else if (k == -2) _vim->key_escape();
else if (k == -3) _vim->key_backspace();
else if (k == -4) _vim->key_tab();
else if (k == -5) _vim->key_redo();
else if (k > 0) _vim->key_char(static_cast<char>(k));
}
EditReq req = EditReq::None;
switch (_vim->take_Signal()) {
case VimBuffer::Signal::Exit: return EditReq::Exit;
case VimBuffer::Signal::WriteQuit: return EditReq::WriteQuit;
case VimBuffer::Signal::QuitDiscard: return EditReq::Discard;
case VimBuffer::Signal::Write: req = EditReq::Write; break;
case VimBuffer::Signal::Help:
_status = {
"-- VIM HELP --",
"i a A o O insert Esc normal h j k l move",
"0 $ line ends gg G :N jump x dd delete",
"yy yw dw cw cc yank/change p P paste >> << indent",
":w write :q quit :wq write+quit :q! discard",
};
break;
default: break;
}
const bool edited = (_vim->get_Version() != _vim_seen_version);
const bool moved = (_vim->get_Row() != row_before);
if (edited || moved) {
_vim_seen_version = _vim->get_Version();
relayout_Editing(); // re-anchor for scrolloff (motion) or rebuild (edit)
if (edited) mark_Dirty(); // re-highlight only when text actually changed
}
return req;
}
void
Handler::mark_Saved()
{
if (_vim) _vim->mark_Saved();
}
int
Handler::cursor_Row() const
{ return _vim ? _vim->get_Row() : 0; }
int
Handler::cursor_Col()
const { return _vim ? _vim->get_Col() : 0; }
int
Handler::cursor_VisualCol() const
{
if (!_vim) return 0;
const auto& lines = _vim->get_Lines();
const int r = _vim->get_Row();
const int bc = _vim->get_Col();
if (r < 0 || r >= (int)lines.size()) return bc;
const std::string& L = lines[(size_t)r];
int vcol = 0;
for (int i = 0; i < bc && i < (int)L.size(); i++)
vcol += (L[(size_t)i] == '\t') ? (4 - (vcol % 4)) : 1;
return vcol;
}
bool
Handler::cursor_ToToken(Token::Data* t)
{
if (!_vim || !t) return false;
for (size_t line_idx = 0; line_idx < _lines.size(); ++line_idx) {
const auto& toks = _lines[line_idx].tokens;
size_t col = 0; // byte column of token start
for (size_t token_idx = 0; token_idx < toks.size(); ++token_idx) {
if (toks[token_idx].get() == t) {
_vim->set_Cursor((int)line_idx, (int)col);
_vim_seen_version = _vim->get_Version();
relayout_Editing(); // cursor moved → re-anchor the view
return true;
}
if (toks[token_idx]) col += toks[token_idx]->text.size(); // raw byte length (space/tab = 1)
}
}
return false;
}
bool
Handler::cursor_AtImpact(const Vec3& impact)
{
if (!_vim || _char_w <= 0.0) return false;
const auto& blines = _vim->get_Lines();
const int N = static_cast<int>(blines.size());
if (N == 0) return false;
// row from height
int row = static_cast<int>(SDL_lround((_row0_y - impact.y) / _line_height));
row = SDL_clamp(row, 0, N - 1);
// target visual column from horizontal position
int vc = static_cast<int>(SDL_lround((_code_left_z - impact.z) / _char_w));
if (vc < 0) vc = 0;
// visual column → byte column (tabs span to the next multiple of 4)
const std::string& s = blines[static_cast<size_t>(row)];
int byte = 0, vcol = 0;
while (byte < static_cast<int>(s.size()) && vcol < vc) {
vcol += (s[(size_t)byte] == '\t') ? (4 - (vcol % 4)) : 1;
++byte;
}
_vim->set_Cursor(row, byte);
_vim_seen_version = _vim->get_Version();
relayout_Editing();
return true;
}
Handler::CursorHit
Handler::resolve_Impact(const Vec3& impact) const
{
CursorHit h;
if (!_vim || _char_w <= 0.0) return h;
const auto& blines = _vim->get_Lines();
const int N = static_cast<int>(blines.size());
if (N == 0) return h;
int row = static_cast<int>(SDL_lround((_row0_y - impact.y) / _line_height));
row = SDL_clamp(row, 0, N - 1);
int vc = static_cast<int>(SDL_lround((_code_left_z - impact.z) / _char_w));
if (vc < 0) vc = 0;
const std::string& s = blines[static_cast<size_t>(row)];
int byte = 0, vcol = 0;
while (byte < static_cast<int>(s.size()) && vcol < vc) {
vcol += (s[(size_t)byte] == '\t') ? (4 - (vcol % 4)) : 1;
++byte;
}
h.row = row; h.byte = byte; h.ok = true;
return h;
}
void
Handler::place_CursorInsert(const CursorHit& hit)
{
if (!_vim || !hit.ok) return;
_vim->enter_Insert(); // insert mode so the cursor can sit at end-of-line
_vim->set_Cursor(hit.row, hit.byte);
_vim_seen_version = _vim->get_Version();
relayout_Editing(); // re-anchor: scrolls this cursor into the player's view
}
VimBuffer::Mode
Handler::cursor_Mode() const
{ return _vim ? _vim->get_Mode() : VimBuffer::Mode::Normal; }
const std::string&
Handler::cmdline_Text() const
{
static const std::string empty;
return _vim ? _vim->get_Cmdline() : empty;
}
const DynArray<std::string>&
Handler::status_Lines() const
{ return _status; }
std::string
Handler::modeline() const
{
if (!_vim) return {};
if (_vim->get_Mode() == VimBuffer::Mode::Command) return _vim->get_Cmdline();
if (!_status.empty()) return _status[0];
if (!_vim->get_Status().empty()) return _vim->get_Status();
return (_vim->get_Mode() == VimBuffer::Mode::Insert) ? "-- INSERT --"
: "-- NORMAL --";
}
std::string
Handler::vim_ShowCmd() const
{
return _vim ? _vim->get_ShowCmd() : std::string{};
}
void
Handler::set_Style(SDL_FColor color, float depth, float scale)
{
_style_color = color;
_depth = depth;
_scale = scale;
}
void
Handler::set_Centre(const Vec3& p)
{
_layout = LayoutMode::FaceCamera;
_pos_mode = PositionMode::Absolute;
_centre = p;
_anchor_ptr = nullptr;
}
void
Handler::attach_To(const Vec3* anchor, const Vec3& offset)
{
_layout = LayoutMode::FaceCamera;
_pos_mode = PositionMode::Anchor;
_anchor_ptr = anchor;
_attach_offset = offset;
}
void
Handler::detach()
{
_pos_mode = PositionMode::Absolute;
_anchor_ptr = nullptr;
}
void
Handler::set_Text(const std::string& text)
{
if (text == _label_text && !_lines.empty()) return;
_label_text = text;
_layout = LayoutMode::FaceCamera;
relayout_FaceCamera();
}
void
Handler::set_Text(const std::string& text, SDL_FColor color, float depth, float scale)
{
set_Style(color, depth, scale);
_label_text.clear();
set_Text(text);
}
void
Handler::set_Collidable(bool on)
{
_collidable = on;
if (_lines.empty()) return;
for (TokenPtr& w : _lines[0].tokens) {
if (!w) continue;
w->solid = on;
w->carriable = false;
w->interactable = false;
w->body.mode = on ? PhysicsMode::Static : PhysicsMode::Hover;
}
}
const DynArray<TokenPtr>&
Handler::get_Words() const
{
static const DynArray<TokenPtr> empty;
return _lines.empty() ? empty : _lines[0].tokens;
}
Vec3
Handler::label_Centre() const
{
if (_pos_mode == PositionMode::Anchor && _anchor_ptr)
return { _anchor_ptr->x + _attach_offset.x,
_anchor_ptr->y + _attach_offset.y,
_anchor_ptr->z + _attach_offset.z };
return _centre;
}
void
Handler::relayout_FaceCamera()
{
_lines.clear();
_label_off.clear();
DynArray<std::string> toks;
{
std::istringstream ss(_label_text);
std::string w;
while (ss >> w) toks.push_back(w);
}
if (toks.empty()) return;
DynArray<TokenPtr> built;
built.reserve(toks.size());
for (const std::string& tk : toks)
built.push_back(make_Token(tk, _depth, _scale, Vec3{0,0,0}, _style_color));
float total_w = 0.0f;
for (const TokenPtr& t : built) total_w += t->half_extent * 2.0f + Config::Text3d::word_gap;
total_w -= Config::Text3d::word_gap;
Line line;
float x_cur = -total_w * 0.5f;
for (TokenPtr& t : built) {
const float he = t->half_extent;
_label_off.push_back(static_cast<double>(x_cur + he));
t->carriable = false;
t->interactable = false;
t->solid = _collidable;
t->body.mode = _collidable ? PhysicsMode::Static : PhysicsMode::Hover;
t->body.restitution = 0.55;
t->jiggle_off = Vec3{};
t->jiggle_vel = Vec3{};
line.tokens.push_back(std::move(t));
x_cur += he * 2.0f + Config::Text3d::word_gap;
}
_lines.push_back(std::move(line));
}
void
Handler::facecam_Update(double dt, const Camera& cam)
{
if (_lines.empty()) return;
DynArray<TokenPtr>& words = _lines[0].tokens;
const Vec3 centre = label_Centre();
Vec3 right { 1, 0, 0 };
float yaw_g { 0.0f };
float pitch_g { 0.0f };
if (_facecam) {
Vec3 fwd = cam.pos - centre;
double flen = Math::length(fwd);
if (flen < 0.0001) return;
fwd = fwd / flen;
right = Math::cross(Vec3{0, 1, 0}, fwd);
double rlen = Math::length(right);
if (rlen < 0.0001) return;
right = right / rlen;
yaw_g = SDL_atan2f(static_cast<float>(fwd.x), static_cast<float>(fwd.z));
pitch_g = -SDL_asinf (static_cast<float>(fwd.y));
} else {
yaw_g = _face_yaw;
pitch_g = 0.0f;
right = { SDL_cos(_face_yaw), 0.0, -SDL_sin(_face_yaw) };
}
auto clamp = [](double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); };
for (size_t i = 0; i < words.size() && i < _label_off.size(); i++) {
Token::Data* t = words[i].get();
if (!t || !t->loaded) continue;
const double off = _label_off[i];
const Vec3 anchor {
centre.x + right.x * off,
centre.y + right.y * off,
centre.z + right.z * off,
};
Vec3& o = t->jiggle_off;
Vec3& v = t->jiggle_vel;
const Vec3 acc {
-Config::Text3d::jiggle_stiff * o.x - Config::Text3d::jiggle_damp * v.x,
-Config::Text3d::jiggle_stiff * o.y - Config::Text3d::jiggle_damp * v.y,
-Config::Text3d::jiggle_stiff * o.z - Config::Text3d::jiggle_damp * v.z,
};
v.x += acc.x * dt; v.y += acc.y * dt; v.z += acc.z * dt;
o.x += v.x * dt; o.y += v.y * dt; o.z += v.z * dt;
o.x = clamp(o.x, -Config::Text3d::jiggle_max_off, Config::Text3d::jiggle_max_off);
o.y = clamp(o.y, -Config::Text3d::jiggle_max_off, Config::Text3d::jiggle_max_off);
o.z = clamp(o.z, -Config::Text3d::jiggle_max_off, Config::Text3d::jiggle_max_off);
t->pos = { anchor.x + o.x, anchor.y + o.y, anchor.z + o.z };
t->yaw = yaw_g;
t->pitch = pitch_g;
}
}
void
Handler::kick(size_t word_index, const Vec3& projectile_vel, const Vec3& normal)
{
if (_lines.empty()) return;
DynArray<TokenPtr>& words = _lines[0].tokens;
if (word_index >= words.size() || !words[word_index]) return;
double v_into = -(projectile_vel.x * normal.x +
projectile_vel.y * normal.y +
projectile_vel.z * normal.z);
if (v_into < 0.0) v_into = 0.0;
const double mag = v_into * Config::Text3d::kick_strength;
Vec3& v = words[word_index]->jiggle_vel;
v.x += -normal.x * mag;
v.y += -normal.y * mag;
v.z += -normal.z * mag;
}
} // namespace Text
} // namespace Nimbin