Compare commits
11 commits
2a29e1b0a1
...
a7ba4867ea
| Author | SHA1 | Date | |
|---|---|---|---|
| a7ba4867ea | |||
| 55968d4c09 | |||
| 53234288ad | |||
| 57db8cb87b | |||
| 112f3571f5 | |||
| 48eef0b095 | |||
| bd0b913d7d | |||
| 30abb0b7ec | |||
| c72ab22999 | |||
| 464af6a549 | |||
| ce15469ffc |
128 changed files with 8478 additions and 4012 deletions
41
CLAUDE.md
41
CLAUDE.md
|
|
@ -609,34 +609,29 @@ echo 'import mcrfpy; print("Test"); scene = mcrfpy.Scene("test"); scene.activate
|
|||
|
||||
### Understanding Key Macros and Patterns
|
||||
|
||||
#### RET_PY_INSTANCE Macro (UIDrawable.h)
|
||||
This macro handles converting C++ UI objects to their Python equivalents:
|
||||
#### Returning a C++ object to Python (#369)
|
||||
|
||||
**Never `tp_alloc` a wrapper for a C++ object that might already have one.** A fresh
|
||||
wrapper breaks object identity (`x.parent is x.parent` -> False) and silently downgrades
|
||||
a Python subclass to its base type. `RET_PY_INSTANCE`, which older docs described here,
|
||||
no longer exists; use the cache-aware converters instead:
|
||||
|
||||
```cpp
|
||||
RET_PY_INSTANCE(target);
|
||||
// Expands to a switch on target->derived_type() that:
|
||||
// 1. Allocates the correct Python object type (Frame, Caption, Sprite, Grid)
|
||||
// 2. Sets the shared_ptr data member
|
||||
// 3. Returns the PyObject*
|
||||
// Drawables (Frame, Caption, Sprite, GridView, Line, Circle, Arc, Viewport3D)
|
||||
PyObject* obj = UIDrawable::pyobject_for(drawable_shared_ptr); // UIDrawable.h
|
||||
|
||||
// Entities -- convertEntityToPython(), file-local to UIEntityCollection.cpp
|
||||
// GridData -- PyGridData::pyobject_for()
|
||||
```
|
||||
|
||||
Each looks the object up in `PythonObjectCache` by `serial_number` and returns the live
|
||||
wrapper if one exists, only allocating (and re-registering) on a miss. `tp_alloc` is
|
||||
correct **only** in a type's own `tp_init`, where the object is genuinely new.
|
||||
|
||||
#### Collection Patterns
|
||||
- `UICollection` wraps `std::vector<std::shared_ptr<UIDrawable>>`
|
||||
- `UIEntityCollection` wraps `std::list<std::shared_ptr<UIEntity>>`
|
||||
- Different containers require different iteration code (vector vs list)
|
||||
|
||||
#### Python Object Creation Patterns
|
||||
```cpp
|
||||
// Pattern 1: Using tp_alloc (most common)
|
||||
auto o = (PyUIFrameObject*)type->tp_alloc(type, 0);
|
||||
o->data = std::make_shared<UIFrame>();
|
||||
|
||||
// Pattern 2: Getting type from module
|
||||
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity");
|
||||
auto o = (PyUIEntityObject*)type->tp_alloc(type, 0);
|
||||
|
||||
// Pattern 3: Direct shared_ptr assignment
|
||||
iterObj->data = self->data; // Shares the C++ object
|
||||
```
|
||||
- `UIEntityCollection` wraps `std::vector<std::shared_ptr<UIEntity>>` (#329: was a
|
||||
`std::list`; indexing is O(1) now, so don't reintroduce `std::advance`)
|
||||
|
||||
### Working Directory Structure
|
||||
```
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -86,6 +86,12 @@ CommandLineParser::ParseResult CommandLineParser::parse(McRogueFaceConfig& confi
|
|||
continue;
|
||||
}
|
||||
|
||||
if (arg == "--run-forever") {
|
||||
config.run_forever = true;
|
||||
current_arg++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg == "--audio-off") {
|
||||
config.audio_enabled = false;
|
||||
current_arg++;
|
||||
|
|
|
|||
|
|
@ -394,8 +394,9 @@ void GameEngine::run()
|
|||
|
||||
void GameEngine::doFrame()
|
||||
{
|
||||
// Reset per-frame metrics
|
||||
metrics.resetPerFrame();
|
||||
// #341: simulation metrics belong to this frame's sim pass; render counters are
|
||||
// cleared/published around the render pass below.
|
||||
metrics.beginSimFrame();
|
||||
|
||||
currentScene()->update();
|
||||
testTimers();
|
||||
|
|
@ -426,6 +427,8 @@ void GameEngine::doFrame()
|
|||
{
|
||||
}
|
||||
|
||||
metrics.beginRender(); // #341
|
||||
|
||||
// Handle scene transitions
|
||||
if (transition.type != TransitionType::None)
|
||||
{
|
||||
|
|
@ -453,6 +456,8 @@ void GameEngine::doFrame()
|
|||
currentScene()->render();
|
||||
}
|
||||
|
||||
metrics.endRender(); // #341: publish this frame's counters for get_metrics()
|
||||
|
||||
// Update and render profiler overlay (if enabled)
|
||||
if (profilerOverlay && !headless) {
|
||||
profilerOverlay->update(metrics);
|
||||
|
|
@ -923,12 +928,20 @@ float GameEngine::step(float dt) {
|
|||
simulation_time += static_cast<int>(dt * 1000.0f); // Convert seconds to ms
|
||||
}
|
||||
|
||||
// Update animations with the dt in seconds
|
||||
if (actual_dt > 0.0f && actual_dt < 10.0f) { // Sanity check
|
||||
AnimationManager::getInstance().update(actual_dt);
|
||||
}
|
||||
// #350: step() is a full SIMULATION frame -- everything doFrame() does except
|
||||
// render and input, which are deliberately not on the clock (render costs zero
|
||||
// simulation time; see renderScene()). Previously step() advanced only animations
|
||||
// and timers, so under step() a Scene.update() override never fired, scene
|
||||
// transitions never progressed or completed, and current_frame never advanced --
|
||||
// headless behaved measurably differently from a real frame.
|
||||
metrics.beginSimFrame();
|
||||
|
||||
// Test timers with the new simulation time
|
||||
// C++ scene update
|
||||
currentScene()->update();
|
||||
|
||||
// Test timers with the new simulation time.
|
||||
// Kept on simulation_time (not runtime): step() is the deterministic headless
|
||||
// clock, which is the whole point of driving time explicitly from a test.
|
||||
auto it = timers.begin();
|
||||
while (it != timers.end()) {
|
||||
auto timer = it->second;
|
||||
|
|
@ -946,13 +959,46 @@ float GameEngine::step(float dt) {
|
|||
}
|
||||
}
|
||||
|
||||
// Python Scene.update(dt) hook -- the originally-filed omission
|
||||
{
|
||||
ScopedTimer pyTimer(metrics.pythonScriptTime);
|
||||
McRFPy_API::updatePythonScenes(actual_dt);
|
||||
}
|
||||
|
||||
// Update animations with the dt in seconds
|
||||
if (actual_dt > 0.0f && actual_dt < 10.0f) { // Sanity check
|
||||
ScopedTimer animTimer(metrics.animationTime);
|
||||
AnimationManager::getInstance().update(actual_dt);
|
||||
}
|
||||
|
||||
// Advance scene transitions, and finalize them when complete. Without this a
|
||||
// transition started headlessly would never end, and the scene never changed.
|
||||
if (transition.type != TransitionType::None) {
|
||||
transition.update(actual_dt);
|
||||
if (transition.isComplete()) {
|
||||
scene = transition.toScene;
|
||||
transition.type = TransitionType::None;
|
||||
McRFPy_API::triggerSceneChange(transition.fromScene, transition.toScene);
|
||||
}
|
||||
}
|
||||
|
||||
metrics.endSimFrame();
|
||||
metrics.updateFrameTime(actual_dt * 1000.0f); // ms
|
||||
currentFrame++;
|
||||
|
||||
return actual_dt;
|
||||
}
|
||||
|
||||
// #153 - Force render the current scene (for synchronous screenshots)
|
||||
// Render the current state on demand, advancing NO simulation time (#341/#350).
|
||||
// Rendering is orthogonal to the clock: any state can be drawn at any moment. This is
|
||||
// the path a screenshot takes, and -- since step() deliberately never renders -- it is
|
||||
// how a headless script gets real render metrics without the clock moving.
|
||||
void GameEngine::renderScene() {
|
||||
if (!render_target) return;
|
||||
|
||||
metrics.beginRender();
|
||||
|
||||
// Handle scene transitions
|
||||
if (transition.type != TransitionType::None) {
|
||||
transition.update(0); // Don't advance transition time, just render current state
|
||||
|
|
@ -963,6 +1009,8 @@ void GameEngine::renderScene() {
|
|||
currentScene()->render();
|
||||
}
|
||||
|
||||
metrics.endRender();
|
||||
|
||||
// For RenderTexture (headless), we need to call display()
|
||||
if (headless && headless_renderer) {
|
||||
headless_renderer->display();
|
||||
|
|
|
|||
|
|
@ -53,37 +53,90 @@ struct ProfilingMetrics {
|
|||
static constexpr int HISTORY_SIZE = 60;
|
||||
float frameTimeHistory[HISTORY_SIZE] = {0};
|
||||
int historyIndex = 0;
|
||||
int historyCount = 0; // #341: frames recorded so far, capped at HISTORY_SIZE
|
||||
|
||||
/**
|
||||
* @brief Per-frame values from the last COMPLETED frame (#341).
|
||||
*
|
||||
* The counters above are live accumulators: they are zeroed at the top of the
|
||||
* frame and filled during render(), which runs AFTER every Python callback.
|
||||
* A script calling get_metrics() therefore always read zeros. These published
|
||||
* copies hold the previous frame's totals, which is what Python should see.
|
||||
*
|
||||
* In-engine consumers that run after render() (ProfilerOverlay, BenchmarkLogger)
|
||||
* keep reading the live values -- they are fresher and already correct there.
|
||||
*/
|
||||
struct PublishedFrame {
|
||||
int drawCalls = 0;
|
||||
int uiElements = 0;
|
||||
int visibleElements = 0;
|
||||
int gridCellsRendered = 0;
|
||||
int entitiesRendered = 0;
|
||||
int totalEntities = 0;
|
||||
float gridRenderTime = 0.0f;
|
||||
float entityRenderTime = 0.0f;
|
||||
float fovOverlayTime = 0.0f;
|
||||
float pythonScriptTime = 0.0f;
|
||||
float animationTime = 0.0f;
|
||||
} published;
|
||||
|
||||
void updateFrameTime(float deltaMs) {
|
||||
frameTime = deltaMs;
|
||||
frameTimeHistory[historyIndex] = deltaMs;
|
||||
historyIndex = (historyIndex + 1) % HISTORY_SIZE;
|
||||
if (historyCount < HISTORY_SIZE) ++historyCount;
|
||||
|
||||
// Calculate average
|
||||
// Average over frames actually recorded. Dividing by the full HISTORY_SIZE
|
||||
// while the zero-initialized buffer is still filling diluted the average and
|
||||
// reported a wildly inflated FPS for the first 60 frames (#341).
|
||||
float sum = 0.0f;
|
||||
for (int i = 0; i < HISTORY_SIZE; ++i) {
|
||||
for (int i = 0; i < historyCount; ++i) {
|
||||
sum += frameTimeHistory[i];
|
||||
}
|
||||
avgFrameTime = sum / HISTORY_SIZE;
|
||||
avgFrameTime = historyCount > 0 ? sum / historyCount : 0.0f;
|
||||
fps = avgFrameTime > 0 ? static_cast<int>(1000.0f / avgFrameTime) : 0;
|
||||
}
|
||||
|
||||
void resetPerFrame() {
|
||||
// #341/#350: simulation and rendering are separate passes with separate metrics.
|
||||
//
|
||||
// Rendering costs zero simulation time and can happen at any moment (a screenshot
|
||||
// renders arbitrary state without advancing the clock), so the render counters are
|
||||
// owned by the render pass and the simulation timings by the step/frame pass.
|
||||
// Keeping them separate means a step() cannot wipe the counters from the last
|
||||
// render, and a render cannot disturb the simulation clock.
|
||||
|
||||
void beginRender() {
|
||||
drawCalls = 0;
|
||||
uiElements = 0;
|
||||
visibleElements = 0;
|
||||
|
||||
// Reset per-frame timing metrics
|
||||
gridRenderTime = 0.0f;
|
||||
entityRenderTime = 0.0f;
|
||||
fovOverlayTime = 0.0f;
|
||||
pythonScriptTime = 0.0f;
|
||||
animationTime = 0.0f;
|
||||
|
||||
// Reset per-frame counters
|
||||
gridCellsRendered = 0;
|
||||
entitiesRendered = 0;
|
||||
totalEntities = 0;
|
||||
gridRenderTime = 0.0f;
|
||||
entityRenderTime = 0.0f;
|
||||
fovOverlayTime = 0.0f;
|
||||
}
|
||||
|
||||
void endRender() {
|
||||
published.drawCalls = drawCalls;
|
||||
published.uiElements = uiElements;
|
||||
published.visibleElements = visibleElements;
|
||||
published.gridCellsRendered = gridCellsRendered;
|
||||
published.entitiesRendered = entitiesRendered;
|
||||
published.totalEntities = totalEntities;
|
||||
published.gridRenderTime = gridRenderTime;
|
||||
published.entityRenderTime = entityRenderTime;
|
||||
published.fovOverlayTime = fovOverlayTime;
|
||||
}
|
||||
|
||||
void beginSimFrame() {
|
||||
pythonScriptTime = 0.0f;
|
||||
animationTime = 0.0f;
|
||||
}
|
||||
|
||||
void endSimFrame() {
|
||||
published.pythonScriptTime = pythonScriptTime;
|
||||
published.animationTime = animationTime;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1707,8 +1707,13 @@ int PyGridLayerAPI::ColorLayer_set_z_index(PyColorLayerObject* self, PyObject* v
|
|||
}
|
||||
long z = PyLong_AsLong(value);
|
||||
if (PyErr_Occurred()) return -1;
|
||||
self->data->z_index = z;
|
||||
// TODO: Trigger re-sort in parent grid
|
||||
if (z != self->data->z_index) {
|
||||
self->data->z_index = z;
|
||||
// #376: the render early-out (#351) keys on content_generation, which only
|
||||
// markDirty() bumps. Without this the view re-blits its stale cached texture
|
||||
// and the new layer order never appears. (sortLayers() runs every render.)
|
||||
self->data->markDirty();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
@ -1727,7 +1732,12 @@ int PyGridLayerAPI::ColorLayer_set_visible(PyColorLayerObject* self, PyObject* v
|
|||
}
|
||||
int v = PyObject_IsTrue(value);
|
||||
if (v < 0) return -1;
|
||||
self->data->visible = v;
|
||||
if (v != self->data->visible) {
|
||||
self->data->visible = v;
|
||||
// #376: data mutators (fill/set/edit) markDirty(); this setter did not, so
|
||||
// hiding or showing a layer left the cached render untouched in both directions.
|
||||
self->data->markDirty();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
@ -2391,8 +2401,10 @@ int PyGridLayerAPI::TileLayer_set_z_index(PyTileLayerObject* self, PyObject* val
|
|||
}
|
||||
long z = PyLong_AsLong(value);
|
||||
if (PyErr_Occurred()) return -1;
|
||||
self->data->z_index = z;
|
||||
// TODO: Trigger re-sort in parent grid
|
||||
if (z != self->data->z_index) {
|
||||
self->data->z_index = z;
|
||||
self->data->markDirty(); // #376: see ColorLayer_set_z_index
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
@ -2411,7 +2423,10 @@ int PyGridLayerAPI::TileLayer_set_visible(PyTileLayerObject* self, PyObject* val
|
|||
}
|
||||
int v = PyObject_IsTrue(value);
|
||||
if (v < 0) return -1;
|
||||
self->data->visible = v;
|
||||
if (v != self->data->visible) {
|
||||
self->data->visible = v;
|
||||
self->data->markDirty(); // #376: see ColorLayer_set_visible
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -127,108 +127,175 @@ static PyObject* mcrfpy_sync_storage(PyObject* self, PyObject* args)
|
|||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
// #151: Module-level __getattr__ for dynamic properties (current_scene, scenes)
|
||||
static PyObject* mcrfpy_module_getattr(PyObject* self, PyObject* args)
|
||||
// #151/#356: the module's dynamic attributes, as real descriptors on the module type.
|
||||
//
|
||||
// These were originally a PEP-562 `__getattr__` plus a string-matching `tp_setattro`.
|
||||
// That worked for getattr/setattr but left them invisible to `dir(mcrfpy)`, and every
|
||||
// doc/stub/manifest generator discovers module symbols via dir() -- so the single most
|
||||
// common idiom in the engine (`mcrfpy.current_scene = ...`) appeared in no generated
|
||||
// documentation at all. Descriptors are introspectable and carry their own docstrings.
|
||||
|
||||
static PyObject* mcrfpy_get_current_scene(PyObject* self, void* closure)
|
||||
{
|
||||
const char* name;
|
||||
if (!PyArg_ParseTuple(args, "s", &name)) {
|
||||
return McRFPy_API::api_get_current_scene();
|
||||
}
|
||||
|
||||
static int mcrfpy_set_current_scene(PyObject* self, PyObject* value, void* closure)
|
||||
{
|
||||
if (!value) {
|
||||
PyErr_SetString(PyExc_AttributeError, "cannot delete 'current_scene'");
|
||||
return -1;
|
||||
}
|
||||
return McRFPy_API::api_set_current_scene(value);
|
||||
}
|
||||
|
||||
static PyObject* mcrfpy_get_scenes(PyObject* self, void* closure)
|
||||
{
|
||||
return McRFPy_API::api_get_scenes();
|
||||
}
|
||||
|
||||
static PyObject* mcrfpy_get_timers(PyObject* self, void* closure)
|
||||
{
|
||||
return McRFPy_API::api_get_timers();
|
||||
}
|
||||
|
||||
static PyObject* mcrfpy_get_animations(PyObject* self, void* closure)
|
||||
{
|
||||
return McRFPy_API::api_get_animations();
|
||||
}
|
||||
|
||||
static PyObject* mcrfpy_get_default_transition(PyObject* self, void* closure)
|
||||
{
|
||||
return PyTransition::to_python(PyTransition::default_transition);
|
||||
}
|
||||
|
||||
static int mcrfpy_set_default_transition(PyObject* self, PyObject* value, void* closure)
|
||||
{
|
||||
if (!value) {
|
||||
PyErr_SetString(PyExc_AttributeError, "cannot delete 'default_transition'");
|
||||
return -1;
|
||||
}
|
||||
TransitionType trans;
|
||||
if (!PyTransition::from_arg(value, &trans, nullptr)) {
|
||||
return -1;
|
||||
}
|
||||
PyTransition::default_transition = trans;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static PyObject* mcrfpy_get_default_transition_duration(PyObject* self, void* closure)
|
||||
{
|
||||
return PyFloat_FromDouble(PyTransition::default_duration);
|
||||
}
|
||||
|
||||
static int mcrfpy_set_default_transition_duration(PyObject* self, PyObject* value, void* closure)
|
||||
{
|
||||
if (!value) {
|
||||
PyErr_SetString(PyExc_AttributeError, "cannot delete 'default_transition_duration'");
|
||||
return -1;
|
||||
}
|
||||
double duration;
|
||||
if (PyFloat_Check(value)) {
|
||||
duration = PyFloat_AsDouble(value);
|
||||
} else if (PyLong_Check(value)) {
|
||||
duration = PyLong_AsDouble(value);
|
||||
} else {
|
||||
PyErr_SetString(PyExc_TypeError, "default_transition_duration must be a number");
|
||||
return -1;
|
||||
}
|
||||
if (duration < 0.0) {
|
||||
PyErr_SetString(PyExc_ValueError, "default_transition_duration must be non-negative");
|
||||
return -1;
|
||||
}
|
||||
PyTransition::default_duration = static_cast<float>(duration);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static PyObject* mcrfpy_get_save_dir(PyObject* self, void* closure)
|
||||
{
|
||||
#ifdef __EMSCRIPTEN__
|
||||
return PyUnicode_FromString("/save");
|
||||
#else
|
||||
return PyUnicode_FromString("save");
|
||||
#endif
|
||||
}
|
||||
|
||||
static PyGetSetDef mcrfpy_module_getset[] = {
|
||||
{"current_scene", mcrfpy_get_current_scene, mcrfpy_set_current_scene,
|
||||
MCRF_PROPERTY(current_scene,
|
||||
"The active scene (Scene). Assign a Scene object, or a scene name as a string, "
|
||||
"to switch scenes."
|
||||
), NULL},
|
||||
{"scenes", mcrfpy_get_scenes, NULL,
|
||||
MCRF_PROPERTY(scenes,
|
||||
"Tuple of all registered Scene objects (tuple, read-only)."
|
||||
), NULL},
|
||||
{"timers", mcrfpy_get_timers, NULL,
|
||||
MCRF_PROPERTY(timers,
|
||||
"Tuple of all active Timer objects (tuple, read-only)."
|
||||
), NULL},
|
||||
{"animations", mcrfpy_get_animations, NULL,
|
||||
MCRF_PROPERTY(animations,
|
||||
"Tuple of all currently running Animation objects (tuple, read-only)."
|
||||
), NULL},
|
||||
{"default_transition", mcrfpy_get_default_transition, mcrfpy_set_default_transition,
|
||||
MCRF_PROPERTY(default_transition,
|
||||
"Default transition (Transition) applied when switching scenes without an "
|
||||
"explicit transition."
|
||||
), NULL},
|
||||
{"default_transition_duration", mcrfpy_get_default_transition_duration,
|
||||
mcrfpy_set_default_transition_duration,
|
||||
MCRF_PROPERTY(default_transition_duration,
|
||||
"Default scene-transition duration in seconds (float). Must be non-negative."
|
||||
), NULL},
|
||||
{"save_dir", mcrfpy_get_save_dir, NULL,
|
||||
MCRF_PROPERTY(save_dir,
|
||||
"Directory used for persistent save data (str, read-only). '/save' under Emscripten."
|
||||
), NULL},
|
||||
{NULL} // Sentinel
|
||||
};
|
||||
|
||||
// #356: PyModule_Type.__dir__ returns only the module __dict__ keys, so descriptors
|
||||
// living on the module *type* would still be invisible to dir(). Override it to union
|
||||
// the two -- this is what actually makes the generators (and tab-completion) see them.
|
||||
static PyObject* mcrfpy_module_dir(PyObject* self, PyObject* Py_UNUSED(ignored))
|
||||
{
|
||||
PyObject* dict = PyModule_GetDict(self); // borrowed
|
||||
if (!dict) return NULL;
|
||||
|
||||
PyObject* names = PyDict_Keys(dict);
|
||||
if (!names) return NULL;
|
||||
|
||||
for (PyGetSetDef* gs = mcrfpy_module_getset; gs->name != NULL; gs++) {
|
||||
PyObject* name = PyUnicode_FromString(gs->name);
|
||||
if (!name || PyList_Append(names, name) < 0) {
|
||||
Py_XDECREF(name);
|
||||
Py_DECREF(names);
|
||||
return NULL;
|
||||
}
|
||||
Py_DECREF(name);
|
||||
}
|
||||
|
||||
if (PyList_Sort(names) < 0) {
|
||||
Py_DECREF(names);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (strcmp(name, "current_scene") == 0) {
|
||||
return McRFPy_API::api_get_current_scene();
|
||||
}
|
||||
|
||||
if (strcmp(name, "scenes") == 0) {
|
||||
return McRFPy_API::api_get_scenes();
|
||||
}
|
||||
|
||||
if (strcmp(name, "timers") == 0) {
|
||||
return McRFPy_API::api_get_timers();
|
||||
}
|
||||
|
||||
if (strcmp(name, "animations") == 0) {
|
||||
return McRFPy_API::api_get_animations();
|
||||
}
|
||||
|
||||
if (strcmp(name, "default_transition") == 0) {
|
||||
return PyTransition::to_python(PyTransition::default_transition);
|
||||
}
|
||||
|
||||
if (strcmp(name, "default_transition_duration") == 0) {
|
||||
return PyFloat_FromDouble(PyTransition::default_duration);
|
||||
}
|
||||
|
||||
if (strcmp(name, "save_dir") == 0) {
|
||||
#ifdef __EMSCRIPTEN__
|
||||
return PyUnicode_FromString("/save");
|
||||
#else
|
||||
return PyUnicode_FromString("save");
|
||||
#endif
|
||||
}
|
||||
|
||||
// Attribute not found - raise AttributeError
|
||||
PyErr_Format(PyExc_AttributeError, "module 'mcrfpy' has no attribute '%s'", name);
|
||||
return NULL;
|
||||
return names;
|
||||
}
|
||||
|
||||
// #151: Custom module type with __setattr__ support for current_scene
|
||||
static int mcrfpy_module_setattro(PyObject* self, PyObject* name, PyObject* value)
|
||||
{
|
||||
const char* name_str = PyUnicode_AsUTF8(name);
|
||||
if (!name_str) return -1;
|
||||
static PyMethodDef mcrfpy_module_type_methods[] = {
|
||||
{"__dir__", mcrfpy_module_dir, METH_NOARGS,
|
||||
MCRF_METHOD(mcrfpy, __dir__,
|
||||
MCRF_SIG("()", "list"),
|
||||
MCRF_DESC("Names of the module's attributes, including the dynamic properties "
|
||||
"(current_scene, scenes, timers, ...) that live as descriptors on the "
|
||||
"module type rather than in the module dict."),
|
||||
MCRF_RETURNS("list: sorted attribute names")
|
||||
)},
|
||||
{NULL} // Sentinel
|
||||
};
|
||||
|
||||
if (strcmp(name_str, "current_scene") == 0) {
|
||||
return McRFPy_API::api_set_current_scene(value);
|
||||
}
|
||||
|
||||
if (strcmp(name_str, "scenes") == 0) {
|
||||
PyErr_SetString(PyExc_AttributeError, "'scenes' is read-only");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (strcmp(name_str, "timers") == 0) {
|
||||
PyErr_SetString(PyExc_AttributeError, "'timers' is read-only");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (strcmp(name_str, "animations") == 0) {
|
||||
PyErr_SetString(PyExc_AttributeError, "'animations' is read-only");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (strcmp(name_str, "default_transition") == 0) {
|
||||
TransitionType trans;
|
||||
if (!PyTransition::from_arg(value, &trans, nullptr)) {
|
||||
return -1;
|
||||
}
|
||||
PyTransition::default_transition = trans;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(name_str, "default_transition_duration") == 0) {
|
||||
double duration;
|
||||
if (PyFloat_Check(value)) {
|
||||
duration = PyFloat_AsDouble(value);
|
||||
} else if (PyLong_Check(value)) {
|
||||
duration = PyLong_AsDouble(value);
|
||||
} else {
|
||||
PyErr_SetString(PyExc_TypeError, "default_transition_duration must be a number");
|
||||
return -1;
|
||||
}
|
||||
if (duration < 0.0) {
|
||||
PyErr_SetString(PyExc_ValueError, "default_transition_duration must be non-negative");
|
||||
return -1;
|
||||
}
|
||||
PyTransition::default_duration = static_cast<float>(duration);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// For other attributes, use default module setattr
|
||||
return PyObject_GenericSetAttr(self, name, value);
|
||||
}
|
||||
|
||||
// Custom module type that inherits from PyModule_Type but has our __setattr__
|
||||
// Custom module type: carries the dynamic properties as descriptors (#151, #356)
|
||||
static PyTypeObject McRFPyModuleType = {
|
||||
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
||||
.tp_name = "mcrfpy.module",
|
||||
|
|
@ -246,11 +313,15 @@ static PyTypeObject McRFPyModuleType = {
|
|||
.tp_hash = NULL,
|
||||
.tp_call = NULL,
|
||||
.tp_str = NULL,
|
||||
// tp_getattro/tp_setattro inherited from PyModule_Type: generic attribute access
|
||||
// finds the tp_getset data descriptors below before touching the module dict.
|
||||
.tp_getattro = NULL,
|
||||
.tp_setattro = mcrfpy_module_setattro,
|
||||
.tp_setattro = NULL,
|
||||
.tp_as_buffer = NULL,
|
||||
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE,
|
||||
.tp_doc = "McRogueFace module with property support",
|
||||
.tp_methods = mcrfpy_module_type_methods,
|
||||
.tp_getset = mcrfpy_module_getset,
|
||||
};
|
||||
|
||||
static PyMethodDef mcrfpyMethods[] = {
|
||||
|
|
@ -307,7 +378,10 @@ static PyMethodDef mcrfpyMethods[] = {
|
|||
MCRF_METHOD(mcrfpy, get_metrics,
|
||||
MCRF_SIG("()", "dict"),
|
||||
MCRF_DESC("Get current performance metrics."),
|
||||
MCRF_RETURNS("dict: Performance data with keys: frame_time (last frame duration in seconds), avg_frame_time (average frame time), fps (frames per second), draw_calls (number of draw calls), ui_elements (total UI element count), visible_elements (visible element count), current_frame (frame counter), runtime (total runtime in seconds), grid_render_time (grid rendering time in ms), entity_render_time (entity rendering time in ms), fov_overlay_time (FOV overlay rendering time in ms), python_time (Python script execution time in ms), animation_time (animation processing time in ms), grid_cells_rendered (number of grid cells rendered this frame), entities_rendered (number of entities rendered this frame), total_entities (total entity count across all grids)")
|
||||
MCRF_RETURNS("dict: Performance data with keys: frame_time (last frame duration in MILLISECONDS), avg_frame_time (rolling mean frame time over the last 60 frames, in milliseconds), fps (frames per second, derived from avg_frame_time -- a rolling average, not an instantaneous rate), draw_calls (number of draw calls), ui_elements (total UI element count), visible_elements (visible element count), current_frame (frame counter), runtime (total runtime in seconds), grid_render_time (grid rendering time in ms), entity_render_time (entity rendering time in ms), fov_overlay_time (FOV overlay rendering time in ms), python_time (Python script execution time in ms), animation_time (animation processing time in ms), grid_cells_rendered (grid cell draws this frame, counted per layer), entities_rendered (number of entities drawn this frame), total_entities (total entity count across all rendered grids)")
|
||||
MCRF_NOTE("All per-frame counters and timing breakdowns describe the last COMPLETED frame. "
|
||||
"Python callbacks run before the frame is rendered, so the in-progress frame's "
|
||||
"values are not available yet; frame_time, fps, runtime and current_frame are live.")
|
||||
)},
|
||||
|
||||
{"set_dev_console", McRFPy_API::_setDevConsole, METH_VARARGS,
|
||||
|
|
@ -349,12 +423,8 @@ static PyMethodDef mcrfpyMethods[] = {
|
|||
MCRF_NOTE("Messages appear in the 'logs' array of each frame in the output JSON.")
|
||||
)},
|
||||
|
||||
// #151: Module-level attribute access for current_scene and scenes
|
||||
{"__getattr__", mcrfpy_module_getattr, METH_VARARGS,
|
||||
MCRF_METHOD(mcrfpy, __getattr__,
|
||||
MCRF_SIG("(name: str)", "object"),
|
||||
MCRF_DESC("Module-level attribute access for dynamic properties (current_scene, scenes).")
|
||||
)},
|
||||
// #356: the module's dynamic attributes (current_scene, scenes, timers, ...) are
|
||||
// real descriptors on McRFPyModuleType now, not a PEP-562 __getattr__ shim.
|
||||
|
||||
// #219: Thread synchronization
|
||||
{"lock", PyLock::lock, METH_NOARGS,
|
||||
|
|
@ -1609,54 +1679,16 @@ static void find_in_collection(std::vector<std::shared_ptr<UIDrawable>>* collect
|
|||
|
||||
// Check this element's name
|
||||
if (name_matches_pattern(drawable->name, pattern)) {
|
||||
// Convert to Python object using RET_PY_INSTANCE logic
|
||||
PyObject* py_obj = nullptr;
|
||||
|
||||
switch (drawable->derived_type()) {
|
||||
case PyObjectsEnum::UIFRAME: {
|
||||
auto frame = std::static_pointer_cast<UIFrame>(drawable);
|
||||
auto type = &mcrfpydef::PyUIFrameType;
|
||||
auto o = (PyUIFrameObject*)type->tp_alloc(type, 0);
|
||||
if (o) {
|
||||
o->data = frame;
|
||||
py_obj = (PyObject*)o;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UICAPTION: {
|
||||
auto caption = std::static_pointer_cast<UICaption>(drawable);
|
||||
auto type = &mcrfpydef::PyUICaptionType;
|
||||
auto o = (PyUICaptionObject*)type->tp_alloc(type, 0);
|
||||
if (o) {
|
||||
o->data = caption;
|
||||
py_obj = (PyObject*)o;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UISPRITE: {
|
||||
auto sprite = std::static_pointer_cast<UISprite>(drawable);
|
||||
auto type = &mcrfpydef::PyUISpriteType;
|
||||
auto o = (PyUISpriteObject*)type->tp_alloc(type, 0);
|
||||
if (o) {
|
||||
o->data = sprite;
|
||||
py_obj = (PyObject*)o;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UIGRIDVIEW: {
|
||||
auto gridview = std::static_pointer_cast<UIGridView>(drawable);
|
||||
auto type = &mcrfpydef::PyUIGridViewType;
|
||||
auto o = (PyUIGridViewObject*)type->tp_alloc(type, 0);
|
||||
if (o) {
|
||||
o->data = gridview;
|
||||
py_obj = (PyObject*)o;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
// #369: go through the cache-aware converter so find() returns the caller's
|
||||
// existing wrapper (and Python subclass), not a fresh identity-less one.
|
||||
PyObject* py_obj = UIDrawable::pyobject_for(drawable);
|
||||
if (py_obj == Py_None) {
|
||||
Py_DECREF(py_obj);
|
||||
py_obj = nullptr;
|
||||
} else if (!py_obj) {
|
||||
PyErr_Clear(); // unknown derived type: skip, as the old switch did
|
||||
}
|
||||
|
||||
|
||||
if (py_obj) {
|
||||
if (find_all) {
|
||||
PyList_Append(results, py_obj);
|
||||
|
|
@ -1842,22 +1874,27 @@ PyObject* McRFPy_API::_getMetrics(PyObject* self, PyObject* args) {
|
|||
PyDict_SetItemString(dict, "avg_frame_time", PyFloat_FromDouble(game->metrics.avgFrameTime));
|
||||
PyDict_SetItemString(dict, "fps", PyLong_FromLong(game->metrics.fps));
|
||||
|
||||
// #341: report the last COMPLETED frame's per-frame values, not the live
|
||||
// accumulators. Python callbacks run before render(), and the accumulators are
|
||||
// zeroed at the top of the frame -- so reading them live always returned 0.
|
||||
const auto& pub = game->metrics.published;
|
||||
|
||||
// Add draw call metrics
|
||||
PyDict_SetItemString(dict, "draw_calls", PyLong_FromLong(game->metrics.drawCalls));
|
||||
PyDict_SetItemString(dict, "ui_elements", PyLong_FromLong(game->metrics.uiElements));
|
||||
PyDict_SetItemString(dict, "visible_elements", PyLong_FromLong(game->metrics.visibleElements));
|
||||
PyDict_SetItemString(dict, "draw_calls", PyLong_FromLong(pub.drawCalls));
|
||||
PyDict_SetItemString(dict, "ui_elements", PyLong_FromLong(pub.uiElements));
|
||||
PyDict_SetItemString(dict, "visible_elements", PyLong_FromLong(pub.visibleElements));
|
||||
|
||||
// #144 - Add detailed timing breakdown (in milliseconds)
|
||||
PyDict_SetItemString(dict, "grid_render_time", PyFloat_FromDouble(game->metrics.gridRenderTime));
|
||||
PyDict_SetItemString(dict, "entity_render_time", PyFloat_FromDouble(game->metrics.entityRenderTime));
|
||||
PyDict_SetItemString(dict, "fov_overlay_time", PyFloat_FromDouble(game->metrics.fovOverlayTime));
|
||||
PyDict_SetItemString(dict, "python_time", PyFloat_FromDouble(game->metrics.pythonScriptTime));
|
||||
PyDict_SetItemString(dict, "animation_time", PyFloat_FromDouble(game->metrics.animationTime));
|
||||
PyDict_SetItemString(dict, "grid_render_time", PyFloat_FromDouble(pub.gridRenderTime));
|
||||
PyDict_SetItemString(dict, "entity_render_time", PyFloat_FromDouble(pub.entityRenderTime));
|
||||
PyDict_SetItemString(dict, "fov_overlay_time", PyFloat_FromDouble(pub.fovOverlayTime));
|
||||
PyDict_SetItemString(dict, "python_time", PyFloat_FromDouble(pub.pythonScriptTime));
|
||||
PyDict_SetItemString(dict, "animation_time", PyFloat_FromDouble(pub.animationTime));
|
||||
|
||||
// #144 - Add grid-specific metrics
|
||||
PyDict_SetItemString(dict, "grid_cells_rendered", PyLong_FromLong(game->metrics.gridCellsRendered));
|
||||
PyDict_SetItemString(dict, "entities_rendered", PyLong_FromLong(game->metrics.entitiesRendered));
|
||||
PyDict_SetItemString(dict, "total_entities", PyLong_FromLong(game->metrics.totalEntities));
|
||||
PyDict_SetItemString(dict, "grid_cells_rendered", PyLong_FromLong(pub.gridCellsRendered));
|
||||
PyDict_SetItemString(dict, "entities_rendered", PyLong_FromLong(pub.entitiesRendered));
|
||||
PyDict_SetItemString(dict, "total_entities", PyLong_FromLong(pub.totalEntities));
|
||||
|
||||
// Add general metrics
|
||||
PyDict_SetItemString(dict, "current_frame", PyLong_FromLong(game->getFrame()));
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ struct McRogueFaceConfig {
|
|||
// Auto-exit when no timers remain (for --headless --exec automation)
|
||||
bool auto_exit_after_exec = false;
|
||||
|
||||
// Keep running after --exec scripts finish, instead of exiting (#350).
|
||||
// Headless --exec normally exits when the scripts are done: step() is the only
|
||||
// headless clock, so the engine cannot advance timers on its own and would
|
||||
// otherwise spin forever. Pass --run-forever for a long-lived headless process
|
||||
// (a server, a REPL host) that drives itself.
|
||||
bool run_forever = false;
|
||||
|
||||
// Exception handling: exit on first Python callback exception (default: true)
|
||||
// Use --continue-after-exceptions to disable
|
||||
bool exit_on_exception = true;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,38 @@ public:
|
|||
ScopedTimer& operator=(const ScopedTimer&) = delete;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Like ScopedTimer, but ADDS its elapsed time to the target (#341).
|
||||
*
|
||||
* ScopedTimer assigns, which is correct for a metric measured once per frame
|
||||
* (workTime, pythonScriptTime). It is wrong for anything measured once per
|
||||
* *object* per frame -- with two grid views on screen, assignment means the
|
||||
* second view's time overwrites the first's rather than summing. Use this for
|
||||
* any per-frame total accumulated across multiple call sites.
|
||||
*
|
||||
* The target must be zeroed once per frame (ProfilingMetrics::resetPerFrame).
|
||||
*/
|
||||
class ScopedAccumTimer {
|
||||
private:
|
||||
std::chrono::high_resolution_clock::time_point start;
|
||||
float& target_ms;
|
||||
|
||||
public:
|
||||
explicit ScopedAccumTimer(float& target)
|
||||
: target_ms(target)
|
||||
{
|
||||
start = std::chrono::high_resolution_clock::now();
|
||||
}
|
||||
|
||||
~ScopedAccumTimer() {
|
||||
auto end = std::chrono::high_resolution_clock::now();
|
||||
target_ms += std::chrono::duration<float, std::milli>(end - start).count();
|
||||
}
|
||||
|
||||
ScopedAccumTimer(const ScopedAccumTimer&) = delete;
|
||||
ScopedAccumTimer& operator=(const ScopedAccumTimer&) = delete;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Accumulating timer that adds elapsed time to existing value
|
||||
*
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ Scene::Scene(GameEngine* g)
|
|||
game = g;
|
||||
ui_elements = std::make_shared<std::vector<std::shared_ptr<UIDrawable>>>();
|
||||
}
|
||||
|
||||
Scene::~Scene()
|
||||
{
|
||||
UIDrawable::releaseChildPins(ui_elements); // #373
|
||||
}
|
||||
void Scene::registerAction(int code, std::string name)
|
||||
{
|
||||
actions[code] = name;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ protected:
|
|||
public:
|
||||
//Scene();
|
||||
Scene(GameEngine*);
|
||||
// #373: releases the Python-identity pins of ui_elements. Virtual because Scene is
|
||||
// a polymorphic base held and deleted by GameEngine through a Scene*.
|
||||
virtual ~Scene();
|
||||
virtual void update() = 0;
|
||||
virtual void render() = 0;
|
||||
virtual void doAction(std::string, std::string) = 0;
|
||||
|
|
|
|||
|
|
@ -17,130 +17,35 @@
|
|||
|
||||
using namespace mcrfpydef;
|
||||
|
||||
// Local helper function to convert UIDrawable to appropriate Python object
|
||||
// #369: promoted to UIDrawable::pyobject_for so .parent, find(), and collection
|
||||
// indexing all hand back the same wrapper for the same C++ object.
|
||||
static PyObject* convertDrawableToPython(std::shared_ptr<UIDrawable> drawable) {
|
||||
if (!drawable) {
|
||||
Py_RETURN_NONE;
|
||||
return UIDrawable::pyobject_for(drawable);
|
||||
}
|
||||
|
||||
// #377: the parent link is the inverse of collection membership, so EVERY mutator
|
||||
// has to maintain it -- not just append(), which was the only one that did. These
|
||||
// two helpers are the single place that knows how: a scene collection parents via
|
||||
// setParentScene() (it has no owner drawable, so owner.lock() is null and
|
||||
// setParent() would silently unparent the child), a drawable-owned one via
|
||||
// setParent().
|
||||
//
|
||||
// #373: they are also the pin points. setParent()/setParentScene() re-evaluate the
|
||||
// strong ref that keeps a Python subclass wrapper alive while its C++ object is in
|
||||
// a collection, so linking and unlinking here is what makes the pin correct.
|
||||
static void link_child(PyUICollectionObject* self, std::shared_ptr<UIDrawable> drawable) {
|
||||
if (!self->scene_name.empty()) {
|
||||
drawable->setParentScene(self->scene_name);
|
||||
} else {
|
||||
drawable->setParent(self->owner.lock());
|
||||
}
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (drawable->serial_number != 0) {
|
||||
PyObject* cached = PythonObjectCache::getInstance().lookup(drawable->serial_number);
|
||||
if (cached) {
|
||||
return cached; // Already INCREF'd by lookup
|
||||
}
|
||||
}
|
||||
|
||||
PyTypeObject* type = nullptr;
|
||||
PyObject* obj = nullptr;
|
||||
|
||||
switch (drawable->derived_type()) {
|
||||
case PyObjectsEnum::UIFRAME:
|
||||
{
|
||||
type = &PyUIFrameType;
|
||||
auto pyObj = (PyUIFrameObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UIFrame>(drawable);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UICAPTION:
|
||||
{
|
||||
type = &PyUICaptionType;
|
||||
auto pyObj = (PyUICaptionObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UICaption>(drawable);
|
||||
pyObj->font = nullptr;
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UISPRITE:
|
||||
{
|
||||
type = &PyUISpriteType;
|
||||
auto pyObj = (PyUISpriteObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UISprite>(drawable);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UIGRIDVIEW:
|
||||
{
|
||||
type = &PyUIGridViewType;
|
||||
auto pyObj = (PyUIGridViewObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UIGridView>(drawable);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UILINE:
|
||||
{
|
||||
type = &PyUILineType;
|
||||
auto pyObj = (PyUILineObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UILine>(drawable);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UICIRCLE:
|
||||
{
|
||||
type = &PyUICircleType;
|
||||
auto pyObj = (PyUICircleObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UICircle>(drawable);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UIARC:
|
||||
{
|
||||
type = &PyUIArcType;
|
||||
auto pyObj = (PyUIArcObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UIArc>(drawable);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UIVIEWPORT3D:
|
||||
{
|
||||
type = &PyViewport3DType;
|
||||
auto pyObj = (PyViewport3DObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<mcrf::Viewport3D>(drawable);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
PyErr_SetString(PyExc_TypeError, "Unknown UIDrawable derived type");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Re-register in cache if the object has a serial number
|
||||
// This handles the case where the original Python wrapper was GC'd
|
||||
// but the C++ object persists (e.g., inline-created objects added to collections)
|
||||
if (obj && drawable->serial_number != 0) {
|
||||
PyObject* weakref = PyWeakref_NewRef(obj, NULL);
|
||||
if (weakref) {
|
||||
PythonObjectCache::getInstance().registerObject(drawable->serial_number, weakref);
|
||||
Py_DECREF(weakref);
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
// Call BEFORE erasing from the vector: setParent(nullptr) may drop the last strong
|
||||
// ref to the wrapper, whose dealloc releases its shared_ptr to the drawable. While
|
||||
// the vector still holds the element, `drawable` cannot be freed under us.
|
||||
static void unlink_child(std::shared_ptr<UIDrawable> drawable) {
|
||||
drawable->setParent(nullptr);
|
||||
}
|
||||
|
||||
// Helper to extract shared_ptr<UIDrawable> from any UIDrawable Python subclass.
|
||||
|
|
@ -229,8 +134,12 @@ PyObject* UICollection::getitem(PyUICollectionObject* self, Py_ssize_t index) {
|
|||
PyErr_SetString(PyExc_RuntimeError, "the collection store returned a null pointer");
|
||||
return NULL;
|
||||
}
|
||||
while (index < 0) index += self->data->size();
|
||||
if (index > self->data->size() - 1)
|
||||
// Same two traps as setitem(): the old `while (index < 0) index += size()` spun
|
||||
// forever on an empty collection, and `index > size() - 1` underflowed to SIZE_MAX
|
||||
// when size() was 0 -- so the bounds check passed and we indexed an empty vector.
|
||||
Py_ssize_t size = static_cast<Py_ssize_t>(vec->size());
|
||||
if (index < 0) index += size;
|
||||
if (index < 0 || index >= size)
|
||||
{
|
||||
PyErr_SetString(PyExc_IndexError, "UICollection index out of range");
|
||||
return NULL;
|
||||
|
|
@ -248,19 +157,21 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject
|
|||
return -1;
|
||||
}
|
||||
|
||||
// Handle negative indexing
|
||||
while (index < 0) index += self->data->size();
|
||||
|
||||
// Handle negative indexing. NOT a `while (index < 0) index += size()` loop: on an
|
||||
// empty collection that adds zero forever, and `children[-1] = x` hung the engine.
|
||||
Py_ssize_t size = static_cast<Py_ssize_t>(self->data->size());
|
||||
if (index < 0) index += size;
|
||||
|
||||
// Bounds check
|
||||
if (index >= self->data->size()) {
|
||||
if (index < 0 || index >= size) {
|
||||
PyErr_SetString(PyExc_IndexError, "UICollection assignment index out of range");
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
// Handle deletion
|
||||
if (value == NULL) {
|
||||
// #122: Clear the parent before removing
|
||||
(*self->data)[index]->setParent(nullptr);
|
||||
unlink_child((*self->data)[index]);
|
||||
self->data->erase(self->data->begin() + index);
|
||||
// #288: Invalidate parent Frame's render cache
|
||||
auto owner_ptr = self->owner.lock();
|
||||
|
|
@ -276,21 +187,30 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject
|
|||
PyErr_SetString(PyExc_TypeError, "UICollection can only contain Drawable objects");
|
||||
return -1;
|
||||
}
|
||||
int old_z_index = (*vec)[index]->z_index; // Preserve the z_index
|
||||
// #183: Remove from old parent, which may be a scene rather than a drawable.
|
||||
// Do this BEFORE indexing: if the incoming drawable is already a member of THIS
|
||||
// collection, detaching it erases it from `vec` and shifts every later element,
|
||||
// so an index validated against the old size could write past the end.
|
||||
new_drawable->removeFromParent();
|
||||
|
||||
if (index >= static_cast<Py_ssize_t>(vec->size())) {
|
||||
PyErr_SetString(PyExc_IndexError, "UICollection assignment index out of range");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Hold a strong ref: unlinking may release the last ref to the old element's
|
||||
// Python wrapper, whose dealloc drops the wrapper's shared_ptr to it.
|
||||
std::shared_ptr<UIDrawable> old_drawable = (*vec)[index];
|
||||
int old_z_index = old_drawable->z_index; // Preserve the z_index
|
||||
|
||||
// #122: Clear parent of old element
|
||||
(*vec)[index]->setParent(nullptr);
|
||||
|
||||
// #122: Remove new drawable from its old parent if it has one
|
||||
if (auto old_parent = new_drawable->getParent()) {
|
||||
new_drawable->removeFromParent();
|
||||
}
|
||||
unlink_child(old_drawable);
|
||||
|
||||
// Preserve the z_index of the replaced element
|
||||
new_drawable->z_index = old_z_index;
|
||||
|
||||
// #122: Set new parent
|
||||
new_drawable->setParent(self->owner.lock());
|
||||
// #377: parent to the scene, not to a null owner, when this is a scene collection
|
||||
link_child(self, new_drawable);
|
||||
|
||||
// Replace the element
|
||||
(*vec)[index] = new_drawable;
|
||||
|
|
@ -487,10 +407,16 @@ int UICollection::ass_subscript(PyUICollectionObject* self, PyObject* key, PyObj
|
|||
// Sort in descending order and delete
|
||||
std::sort(indices.begin(), indices.end(), std::greater<Py_ssize_t>());
|
||||
for (Py_ssize_t idx : indices) {
|
||||
// #377: unlink before erasing -- a slice delete used to leave the
|
||||
// removed child pointing at a parent that no longer contains it
|
||||
unlink_child((*self->data)[idx]);
|
||||
self->data->erase(self->data->begin() + idx);
|
||||
}
|
||||
} else {
|
||||
// Contiguous slice - can delete in one go
|
||||
for (Py_ssize_t i = start; i < stop; i++) {
|
||||
unlink_child((*self->data)[i]);
|
||||
}
|
||||
self->data->erase(self->data->begin() + start, self->data->begin() + stop);
|
||||
}
|
||||
|
||||
|
|
@ -539,38 +465,67 @@ int UICollection::ass_subscript(PyUICollectionObject* self, PyObject* key, PyObj
|
|||
new_items.push_back(drawable);
|
||||
}
|
||||
|
||||
// Now perform the assignment
|
||||
if (step != 1 && slicelength != value_len) {
|
||||
PyErr_Format(PyExc_ValueError,
|
||||
"attempt to assign sequence of size %zd to extended slice of size %zd",
|
||||
value_len, slicelength);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// #377: build the post-assignment contents as an independent vector before
|
||||
// touching anything. An incoming item may already be a member of THIS
|
||||
// collection, and detaching it from its old parent mutates the very vector
|
||||
// we would otherwise be indexing into -- shifting `start`/`stop` out from
|
||||
// under the erase. Assembling a copy first makes aliasing a non-issue.
|
||||
//
|
||||
// `previous` also holds a strong ref to every displaced element, so
|
||||
// unlinking one (which may release the last ref to its Python wrapper,
|
||||
// whose dealloc drops the wrapper's shared_ptr) cannot free it under us.
|
||||
std::vector<std::shared_ptr<UIDrawable>> previous = *self->data;
|
||||
std::vector<std::shared_ptr<UIDrawable>> result = previous;
|
||||
|
||||
if (step == 1) {
|
||||
// Contiguous slice
|
||||
if (slicelength != value_len) {
|
||||
// Need to resize
|
||||
auto it_start = self->data->begin() + start;
|
||||
auto it_stop = self->data->begin() + stop;
|
||||
self->data->erase(it_start, it_stop);
|
||||
self->data->insert(self->data->begin() + start, new_items.begin(), new_items.end());
|
||||
result.erase(result.begin() + start, result.begin() + stop);
|
||||
result.insert(result.begin() + start, new_items.begin(), new_items.end());
|
||||
} else {
|
||||
// Same size, just replace
|
||||
for (Py_ssize_t i = 0; i < slicelength; i++) {
|
||||
// Preserve z_index
|
||||
new_items[i]->z_index = (*self->data)[start + i]->z_index;
|
||||
(*self->data)[start + i] = new_items[i];
|
||||
new_items[i]->z_index = previous[start + i]->z_index; // Preserve z_index
|
||||
result[start + i] = new_items[i];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Extended slice
|
||||
if (slicelength != value_len) {
|
||||
PyErr_Format(PyExc_ValueError,
|
||||
"attempt to assign sequence of size %zd to extended slice of size %zd",
|
||||
value_len, slicelength);
|
||||
return -1;
|
||||
}
|
||||
for (Py_ssize_t i = 0, cur = start; i < slicelength; i++, cur += step) {
|
||||
// Preserve z_index
|
||||
new_items[i]->z_index = (*self->data)[cur]->z_index;
|
||||
(*self->data)[cur] = new_items[i];
|
||||
new_items[i]->z_index = previous[cur]->z_index; // Preserve z_index
|
||||
result[cur] = new_items[i];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
auto contains_ptr = [](const std::vector<std::shared_ptr<UIDrawable>>& v,
|
||||
const UIDrawable* p) {
|
||||
return std::any_of(v.begin(), v.end(),
|
||||
[p](const std::shared_ptr<UIDrawable>& e) { return e.get() == p; });
|
||||
};
|
||||
|
||||
// Detach incoming items from whatever parent they had. Safe to do now:
|
||||
// this can only mutate `*self->data`, which we are about to overwrite.
|
||||
for (auto& item : new_items) {
|
||||
item->removeFromParent();
|
||||
}
|
||||
|
||||
*self->data = result;
|
||||
|
||||
// Anything the slice displaced is no longer a member -- drop its parent link.
|
||||
for (auto& old_item : previous) {
|
||||
if (!contains_ptr(result, old_item.get())) {
|
||||
unlink_child(old_item);
|
||||
}
|
||||
}
|
||||
for (auto& item : result) {
|
||||
link_child(self, item);
|
||||
}
|
||||
|
||||
|
||||
// Mark scene as needing resort after slice assignment
|
||||
McRFPy_API::markSceneNeedsSort();
|
||||
// #288: Invalidate parent Frame's render cache
|
||||
|
|
@ -639,12 +594,7 @@ PyObject* UICollection::append(PyUICollectionObject* self, PyObject* o)
|
|||
drawable->z_index = new_z_index;
|
||||
|
||||
// #183: Set new parent - either scene or drawable
|
||||
if (!self->scene_name.empty()) {
|
||||
drawable->setParentScene(self->scene_name);
|
||||
} else {
|
||||
std::shared_ptr<UIDrawable> owner_ptr = self->owner.lock();
|
||||
drawable->setParent(owner_ptr);
|
||||
}
|
||||
link_child(self, drawable);
|
||||
|
||||
self->data->push_back(drawable);
|
||||
|
||||
|
|
@ -696,7 +646,9 @@ PyObject* UICollection::extend(PyUICollectionObject* self, PyObject* iterable)
|
|||
|
||||
drawable->removeFromParent();
|
||||
drawable->z_index = current_z_index;
|
||||
drawable->setParent(owner_ptr);
|
||||
// #377: was setParent(owner_ptr), which unparented the drawable outright on a
|
||||
// scene collection (no owner drawable => owner.lock() is null)
|
||||
link_child(self, drawable);
|
||||
self->data->push_back(drawable);
|
||||
|
||||
Py_DECREF(item);
|
||||
|
|
@ -741,7 +693,7 @@ PyObject* UICollection::remove(PyUICollectionObject* self, PyObject* o)
|
|||
for (auto it = vec->begin(); it != vec->end(); ++it) {
|
||||
if (it->get() == search_drawable.get()) {
|
||||
// #122: Clear the parent before removing
|
||||
(*it)->setParent(nullptr);
|
||||
unlink_child(*it);
|
||||
vec->erase(it);
|
||||
McRFPy_API::markSceneNeedsSort();
|
||||
// #288: Invalidate parent Frame's render cache
|
||||
|
|
@ -791,7 +743,7 @@ PyObject* UICollection::pop(PyUICollectionObject* self, PyObject* args)
|
|||
std::shared_ptr<UIDrawable> drawable = (*vec)[index];
|
||||
|
||||
// #122: Clear the parent before removing
|
||||
drawable->setParent(nullptr);
|
||||
unlink_child(drawable);
|
||||
|
||||
// Remove from vector
|
||||
vec->erase(vec->begin() + index);
|
||||
|
|
@ -831,6 +783,12 @@ PyObject* UICollection::insert(PyUICollectionObject* self, PyObject* args)
|
|||
return NULL;
|
||||
}
|
||||
|
||||
// #183: Remove from old parent, which may be a scene rather than a drawable.
|
||||
// Do this BEFORE resolving the index: if the drawable is already a member of
|
||||
// THIS collection, detaching it erases it from `vec`, and an index clamped
|
||||
// against the old size could then land one past the end.
|
||||
drawable->removeFromParent();
|
||||
|
||||
// Handle negative indexing and clamping (Python list.insert behavior)
|
||||
Py_ssize_t size = static_cast<Py_ssize_t>(vec->size());
|
||||
if (index < 0) {
|
||||
|
|
@ -842,13 +800,8 @@ PyObject* UICollection::insert(PyUICollectionObject* self, PyObject* args)
|
|||
index = size;
|
||||
}
|
||||
|
||||
// #122: Remove from old parent if it has one
|
||||
if (auto old_parent = drawable->getParent()) {
|
||||
drawable->removeFromParent();
|
||||
}
|
||||
|
||||
// #122: Set new parent
|
||||
drawable->setParent(self->owner.lock());
|
||||
// #377: parent to the scene, not to a null owner, when this is a scene collection
|
||||
link_child(self, drawable);
|
||||
|
||||
// Insert at position
|
||||
vec->insert(vec->begin() + index, drawable);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
#include "UILine.h"
|
||||
#include "UICircle.h"
|
||||
#include "UIArc.h"
|
||||
#include "3d/Viewport3D.h"
|
||||
#include "GameEngine.h"
|
||||
#include "McRFPy_API.h"
|
||||
#include "PythonObjectCache.h"
|
||||
|
|
@ -942,6 +943,8 @@ void UIDrawable::setParent(std::shared_ptr<UIDrawable> new_parent) {
|
|||
parent = new_parent;
|
||||
parent_scene.clear(); // #183: Clear scene parent when setting drawable parent
|
||||
|
||||
updatePyIdentityPin(); // #373
|
||||
|
||||
// Apply alignment when parent is set (if alignment is configured)
|
||||
if (new_parent && align_type != AlignmentType::NONE) {
|
||||
applyAlignment();
|
||||
|
|
@ -952,16 +955,63 @@ void UIDrawable::setParentScene(const std::string& scene_name) {
|
|||
parent.reset(); // #183: Clear drawable parent when setting scene parent
|
||||
parent_scene = scene_name;
|
||||
|
||||
updatePyIdentityPin(); // #373
|
||||
|
||||
// Apply alignment when scene parent is set (if alignment is configured)
|
||||
if (!scene_name.empty() && align_type != AlignmentType::NONE) {
|
||||
applyAlignment();
|
||||
}
|
||||
}
|
||||
|
||||
// #373: hold a strong ref to the Python wrapper while, and only while, this drawable
|
||||
// is a member of a children collection. See the comment on py_identity in UIDrawable.h.
|
||||
void UIDrawable::updatePyIdentityPin() {
|
||||
// Only subclasses carry state worth preserving, and only they can lose anything
|
||||
// when a wrapper is re-created from the cache miss.
|
||||
if (!is_python_subclass) return;
|
||||
|
||||
const bool in_collection = !parent.expired() || !parent_scene.empty();
|
||||
|
||||
if (in_collection) {
|
||||
if (py_identity) return; // already pinned
|
||||
if (serial_number == 0 || !Py_IsInitialized()) return;
|
||||
// The wrapper necessarily exists: is_python_subclass is only ever set from a
|
||||
// subclass's tp_init, so Python allocated one. A cache miss here would mean the
|
||||
// wrapper was already collected, and there is nothing left to pin.
|
||||
py_identity = PythonObjectCache::getInstance().lookup(serial_number); // new ref
|
||||
} else {
|
||||
releasePyIdentity();
|
||||
}
|
||||
}
|
||||
|
||||
void UIDrawable::releaseChildPins(const std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>>& children) {
|
||||
if (!children) return;
|
||||
for (auto& child : *children) {
|
||||
if (child) child->releasePyIdentity();
|
||||
}
|
||||
}
|
||||
|
||||
void UIDrawable::releasePyIdentity() {
|
||||
if (!py_identity) return;
|
||||
// Null the member BEFORE the DECREF: the wrapper's dealloc drops its shared_ptr to
|
||||
// this drawable, which can re-enter here if that was the last strong ref.
|
||||
PyObject* tmp = py_identity;
|
||||
py_identity = nullptr;
|
||||
if (Py_IsInitialized()) {
|
||||
Py_DECREF(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
std::shared_ptr<UIDrawable> UIDrawable::getParent() const {
|
||||
return parent.lock();
|
||||
}
|
||||
|
||||
// #373: this drops the parent link, so it also drops the Python-identity pin -- which
|
||||
// may release the last reference to the wrapper, whose dealloc drops the wrapper's
|
||||
// shared_ptr to this drawable. Callers must therefore hold their own strong ref for the
|
||||
// duration (every one does: they come through a local shared_ptr from extractDrawable).
|
||||
// updatePyIdentityPin() is the LAST statement on both paths so that even in that case
|
||||
// no member of a freed `this` is touched afterwards.
|
||||
void UIDrawable::removeFromParent() {
|
||||
// #183: Handle scene parent removal
|
||||
if (!parent_scene.empty()) {
|
||||
|
|
@ -975,6 +1025,7 @@ void UIDrawable::removeFromParent() {
|
|||
}
|
||||
}
|
||||
parent_scene.clear();
|
||||
updatePyIdentityPin();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1015,6 +1066,7 @@ void UIDrawable::removeFromParent() {
|
|||
}
|
||||
|
||||
parent.reset();
|
||||
updatePyIdentityPin();
|
||||
}
|
||||
|
||||
// #102 - Global position calculation
|
||||
|
|
@ -1284,6 +1336,133 @@ PyObject* UIDrawable::get_uniforms(PyObject* self, void* closure) {
|
|||
return (PyObject*)collection;
|
||||
}
|
||||
|
||||
// #369: single cache-aware conversion from a C++ drawable to its Python wrapper.
|
||||
// Every path that hands a drawable back to Python must go through here, or object
|
||||
// identity breaks (`x.parent is x.parent` -> False) and Python subclasses are lost.
|
||||
PyObject* UIDrawable::pyobject_for(std::shared_ptr<UIDrawable> drawable) {
|
||||
if (!drawable) {
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
// A live wrapper wins: it carries the caller's subclass and identity.
|
||||
if (drawable->serial_number != 0) {
|
||||
PyObject* cached = PythonObjectCache::getInstance().lookup(drawable->serial_number);
|
||||
if (cached) {
|
||||
return cached; // lookup() already INCREF'd
|
||||
}
|
||||
}
|
||||
|
||||
PyTypeObject* type = nullptr;
|
||||
PyObject* obj = nullptr;
|
||||
|
||||
switch (drawable->derived_type()) {
|
||||
case PyObjectsEnum::UIFRAME:
|
||||
{
|
||||
type = &mcrfpydef::PyUIFrameType;
|
||||
auto pyObj = (PyUIFrameObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UIFrame>(drawable);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UICAPTION:
|
||||
{
|
||||
type = &mcrfpydef::PyUICaptionType;
|
||||
auto pyObj = (PyUICaptionObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UICaption>(drawable);
|
||||
pyObj->font = nullptr;
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UISPRITE:
|
||||
{
|
||||
type = &mcrfpydef::PyUISpriteType;
|
||||
auto pyObj = (PyUISpriteObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UISprite>(drawable);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UIGRIDVIEW:
|
||||
{
|
||||
type = &mcrfpydef::PyUIGridViewType;
|
||||
auto pyObj = (PyUIGridViewObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UIGridView>(drawable);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UILINE:
|
||||
{
|
||||
type = &mcrfpydef::PyUILineType;
|
||||
auto pyObj = (PyUILineObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UILine>(drawable);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UICIRCLE:
|
||||
{
|
||||
type = &mcrfpydef::PyUICircleType;
|
||||
auto pyObj = (PyUICircleObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UICircle>(drawable);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UIARC:
|
||||
{
|
||||
type = &mcrfpydef::PyUIArcType;
|
||||
auto pyObj = (PyUIArcObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UIArc>(drawable);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UIVIEWPORT3D:
|
||||
{
|
||||
type = &mcrfpydef::PyViewport3DType;
|
||||
auto pyObj = (PyViewport3DObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<mcrf::Viewport3D>(drawable);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
PyErr_SetString(PyExc_TypeError, "Unknown UIDrawable derived type");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Register the fresh wrapper so the next lookup returns this same object.
|
||||
// Covers C++-created drawables whose original wrapper was GC'd.
|
||||
if (obj && drawable->serial_number != 0) {
|
||||
PyObject* weakref = PyWeakref_NewRef(obj, NULL);
|
||||
if (weakref) {
|
||||
PythonObjectCache::getInstance().registerObject(drawable->serial_number, weakref);
|
||||
Py_DECREF(weakref);
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Python API - get parent drawable
|
||||
PyObject* UIDrawable::get_parent(PyObject* self, void* closure) {
|
||||
PyObjectsEnum objtype = static_cast<PyObjectsEnum>(reinterpret_cast<intptr_t>(closure));
|
||||
|
|
@ -1299,67 +1478,7 @@ PyObject* UIDrawable::get_parent(PyObject* self, void* closure) {
|
|||
// Scene not found in python_scenes (shouldn't happen, but fall through to None)
|
||||
}
|
||||
|
||||
auto parent_ptr = drawable->getParent();
|
||||
if (!parent_ptr) {
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
// Convert parent to Python object using the cache/conversion system
|
||||
// Re-use the pattern from UICollection
|
||||
PyTypeObject* type = nullptr;
|
||||
PyObject* obj = nullptr;
|
||||
|
||||
switch (parent_ptr->derived_type()) {
|
||||
case PyObjectsEnum::UIFRAME:
|
||||
{
|
||||
type = &mcrfpydef::PyUIFrameType;
|
||||
auto pyObj = (PyUIFrameObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UIFrame>(parent_ptr);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UICAPTION:
|
||||
{
|
||||
type = &mcrfpydef::PyUICaptionType;
|
||||
auto pyObj = (PyUICaptionObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UICaption>(parent_ptr);
|
||||
pyObj->font = nullptr;
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UISPRITE:
|
||||
{
|
||||
type = &mcrfpydef::PyUISpriteType;
|
||||
auto pyObj = (PyUISpriteObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UISprite>(parent_ptr);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UIGRIDVIEW:
|
||||
{
|
||||
type = &mcrfpydef::PyUIGridViewType;
|
||||
auto pyObj = (PyUIGridViewObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UIGridView>(parent_ptr);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
return obj;
|
||||
return UIDrawable::pyobject_for(drawable->getParent());
|
||||
}
|
||||
|
||||
// Python API - set parent drawable (or None to remove from parent)
|
||||
|
|
|
|||
|
|
@ -177,9 +177,49 @@ public:
|
|||
// Remove this drawable from its current parent's children (or scene)
|
||||
void removeFromParent();
|
||||
|
||||
// #373: strong ref to this drawable's Python wrapper, held for exactly as long as
|
||||
// the drawable is a member of a children collection (a Frame's, a GridView's, or a
|
||||
// Scene's). PythonObjectCache holds only weakrefs, so without this a subclass
|
||||
// wrapper that Python stops referencing is collected while C++ still owns the
|
||||
// object -- and the next .parent / find() / indexing lookup misses the cache and
|
||||
// hands back a freshly allocated BASE-type wrapper, silently dropping the subclass
|
||||
// and every attribute the user set on it.
|
||||
//
|
||||
// Membership is the boundary, exactly as grid membership is for UIEntity (#266).
|
||||
// It is represented by the parent link, so the pin is re-evaluated in setParent()
|
||||
// and setParentScene() -- the two choke points every collection mutator goes
|
||||
// through. Only subclassed drawables are pinned: a base-type wrapper carries no
|
||||
// state of its own (the types have no __dict__), so re-creating one is unobservable.
|
||||
//
|
||||
// The pin is a reference cycle (wrapper -> shared_ptr -> drawable -> wrapper) that
|
||||
// Python's GC cannot see through the C++ side, so it MUST be released on the way
|
||||
// out. Every exit is covered: unlinking calls setParent(nullptr), and an owner
|
||||
// destroyed while still holding children releases its children's pins first.
|
||||
PyObject* py_identity = nullptr;
|
||||
|
||||
// Acquire or drop py_identity to match the current parent link. Idempotent.
|
||||
void updatePyIdentityPin();
|
||||
|
||||
// Unconditionally drop the pin. Called by an owner tearing down its children vector.
|
||||
void releasePyIdentity();
|
||||
|
||||
// #373: an owner (Frame, GridView, Scene) destroyed while it still holds children
|
||||
// must drop their pins. Nothing calls setParent(nullptr) on that path -- the vector
|
||||
// simply dies -- so without this the wrapper <-> drawable cycle would strand the
|
||||
// entire subtree in memory. Releasing a child's pin cannot free it here: the vector
|
||||
// still holds a strong ref. It dies with the vector, and its own destructor then
|
||||
// releases the next level down.
|
||||
static void releaseChildPins(const std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>>& children);
|
||||
|
||||
// Get the global (screen) position by walking up the parent chain (#102)
|
||||
sf::Vector2f get_global_position() const;
|
||||
|
||||
// #369: the single cache-aware C++ -> Python wrapper conversion for drawables.
|
||||
// Returns the live wrapper for this drawable if one exists (preserving object
|
||||
// identity and any Python subclass), otherwise allocates one and registers it.
|
||||
// Returns a new reference, or nullptr with an exception set.
|
||||
static PyObject* pyobject_for(std::shared_ptr<UIDrawable> drawable);
|
||||
|
||||
// Python API for parent/global_position
|
||||
static PyObject* get_parent(PyObject* self, void* closure);
|
||||
static int set_parent(PyObject* self, PyObject* value, void* closure);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,40 @@
|
|||
#include <sstream>
|
||||
#include <algorithm>
|
||||
|
||||
// #369: single cache-aware conversion from a C++ entity to its Python wrapper.
|
||||
// Raw tp_alloc here is not merely an identity bug as it is for drawables: a duplicate
|
||||
// wrapper's tp_dealloc unconditionally clears UIEntity::pyobject, destroying the #266
|
||||
// subclass-identity ref held by the *original* wrapper. Every site must use this.
|
||||
static PyObject* convertEntityToPython(std::shared_ptr<UIEntity> entity) {
|
||||
if (!entity) {
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
if (entity->serial_number != 0) {
|
||||
PyObject* cached = PythonObjectCache::getInstance().lookup(entity->serial_number);
|
||||
if (cached) {
|
||||
return cached; // lookup() already INCREF'd
|
||||
}
|
||||
}
|
||||
|
||||
PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType;
|
||||
auto o = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0);
|
||||
if (!o) return NULL;
|
||||
|
||||
o->data = entity;
|
||||
o->weakreflist = NULL;
|
||||
|
||||
if (entity->serial_number != 0) {
|
||||
PyObject* weakref = PyWeakref_NewRef((PyObject*)o, NULL);
|
||||
if (weakref) {
|
||||
PythonObjectCache::getInstance().registerObject(entity->serial_number, weakref);
|
||||
Py_DECREF(weakref);
|
||||
}
|
||||
}
|
||||
|
||||
return (PyObject*)o;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UIEntityCollectionIter implementation
|
||||
// ============================================================================
|
||||
|
|
@ -44,23 +78,7 @@ PyObject* UIEntityCollectionIter::next(PyUIEntityCollectionIterObject* self)
|
|||
auto target = (*self->data)[self->index];
|
||||
++self->index;
|
||||
|
||||
// Check cache first to preserve derived class identity
|
||||
if (target->serial_number != 0) {
|
||||
PyObject* cached = PythonObjectCache::getInstance().lookup(target->serial_number);
|
||||
if (cached) {
|
||||
return cached; // Already INCREF'd by lookup
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise create and return a new Python Entity object
|
||||
PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType;
|
||||
|
||||
auto o = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0);
|
||||
if (!o) return NULL;
|
||||
|
||||
o->data = std::static_pointer_cast<UIEntity>(target);
|
||||
o->weakreflist = NULL;
|
||||
return (PyObject*)o;
|
||||
return convertEntityToPython(std::static_pointer_cast<UIEntity>(target));
|
||||
}
|
||||
|
||||
PyObject* UIEntityCollectionIter::repr(PyUIEntityCollectionIterObject* self)
|
||||
|
|
@ -102,35 +120,7 @@ PyObject* UIEntityCollection::getitem(PyUIEntityCollectionObject* self, Py_ssize
|
|||
// #329 - O(1) random access (was std::advance over a std::list, O(n))
|
||||
auto target = (*vec)[index];
|
||||
|
||||
// Check cache first to preserve derived class
|
||||
if (target->serial_number != 0) {
|
||||
PyObject* cached = PythonObjectCache::getInstance().lookup(target->serial_number);
|
||||
if (cached) {
|
||||
return cached; // Already INCREF'd by lookup
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new base Entity object
|
||||
PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType;
|
||||
|
||||
auto o = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0);
|
||||
if (!o) return NULL;
|
||||
|
||||
o->data = std::static_pointer_cast<UIEntity>(target);
|
||||
o->weakreflist = NULL;
|
||||
|
||||
// Re-register in cache if the entity has a serial number
|
||||
// This handles the case where the original Python wrapper was GC'd
|
||||
// but the C++ object persists (e.g., inline-created objects added to collections)
|
||||
if (target->serial_number != 0) {
|
||||
PyObject* weakref = PyWeakref_NewRef((PyObject*)o, NULL);
|
||||
if (weakref) {
|
||||
PythonObjectCache::getInstance().registerObject(target->serial_number, weakref);
|
||||
Py_DECREF(weakref);
|
||||
}
|
||||
}
|
||||
|
||||
return (PyObject*)o;
|
||||
return convertEntityToPython(std::static_pointer_cast<UIEntity>(target));
|
||||
}
|
||||
|
||||
int UIEntityCollection::setitem(PyUIEntityCollectionObject* self, Py_ssize_t index, PyObject* value) {
|
||||
|
|
@ -244,19 +234,15 @@ PyObject* UIEntityCollection::concat(PyUIEntityCollectionObject* self, PyObject*
|
|||
return NULL;
|
||||
}
|
||||
|
||||
PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType;
|
||||
|
||||
// Add all elements from self
|
||||
Py_ssize_t idx = 0;
|
||||
for (const auto& entity : *self->data) {
|
||||
auto obj = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0);
|
||||
PyObject* obj = convertEntityToPython(entity);
|
||||
if (!obj) {
|
||||
Py_DECREF(result_list);
|
||||
return NULL;
|
||||
}
|
||||
obj->data = entity;
|
||||
obj->weakreflist = NULL;
|
||||
PyList_SET_ITEM(result_list, idx++, (PyObject*)obj);
|
||||
PyList_SET_ITEM(result_list, idx++, obj);
|
||||
}
|
||||
|
||||
// Add all elements from other
|
||||
|
|
@ -358,21 +344,13 @@ PyObject* UIEntityCollection::subscript(PyUIEntityCollectionObject* self, PyObje
|
|||
return NULL;
|
||||
}
|
||||
|
||||
PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType;
|
||||
|
||||
auto it = self->data->begin();
|
||||
for (Py_ssize_t i = 0, cur = start; i < slicelength; i++, cur += step) {
|
||||
auto cur_it = it;
|
||||
std::advance(cur_it, cur);
|
||||
|
||||
auto obj = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0);
|
||||
PyObject* obj = convertEntityToPython((*self->data)[cur]);
|
||||
if (!obj) {
|
||||
Py_DECREF(result_list);
|
||||
return NULL;
|
||||
}
|
||||
obj->data = *cur_it;
|
||||
obj->weakreflist = NULL;
|
||||
PyList_SET_ITEM(result_list, i, (PyObject*)obj);
|
||||
PyList_SET_ITEM(result_list, i, obj);
|
||||
}
|
||||
|
||||
return result_list;
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ UIFrame::UIFrame(float _x, float _y, float _w, float _h)
|
|||
|
||||
UIFrame::~UIFrame()
|
||||
{
|
||||
UIDrawable::releaseChildPins(children); // #373
|
||||
children.reset();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,15 @@ float DijkstraMap::getDistance(int x, int y) const {
|
|||
return TCOD_dijkstra_get_distance(tcod_dijkstra, x, y);
|
||||
}
|
||||
|
||||
// #375: emit the path in origin->destination order, matching find_path()'s convention
|
||||
// (excludes the origin, includes the root).
|
||||
//
|
||||
// libtcod builds its internal list as [pos, ..., root_adjacent] and
|
||||
// TCOD_dijkstra_path_walk() pops from the BACK, so walking yields
|
||||
// [root_adjacent, ..., pos] -- the exact reverse of what path_from's docstring,
|
||||
// AStarPath.origin/.destination, and step_from all promise. stepFrom() inherited the
|
||||
// error and returned a cell adjacent to the ROOT rather than to the query position,
|
||||
// i.e. an arbitrarily long teleport.
|
||||
std::vector<sf::Vector2i> DijkstraMap::getPathFrom(int x, int y) const {
|
||||
std::vector<sf::Vector2i> path;
|
||||
if (!tcod_dijkstra) return path;
|
||||
|
|
@ -75,6 +84,18 @@ std::vector<sf::Vector2i> DijkstraMap::getPathFrom(int x, int y) const {
|
|||
path.push_back(sf::Vector2i(px, py));
|
||||
}
|
||||
}
|
||||
if (path.empty()) return path; // already at a root, or unreachable
|
||||
|
||||
std::reverse(path.begin(), path.end()); // -> [pos, ..., root_adjacent]
|
||||
path.erase(path.begin()); // drop the origin
|
||||
|
||||
// The walk stops one cell short of the root. Append the root this path descends
|
||||
// into -- via descentStep, so the multi-root case lands on the correct root.
|
||||
bool ok = false;
|
||||
sf::Vector2i last = path.empty() ? sf::Vector2i(x, y) : path.back();
|
||||
sf::Vector2i root_cell = descentStep(last.x, last.y, &ok);
|
||||
if (ok) path.push_back(root_cell);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
|
|
@ -84,15 +105,11 @@ sf::Vector2i DijkstraMap::stepFrom(int x, int y, bool* valid) const {
|
|||
return sf::Vector2i(-1, -1);
|
||||
}
|
||||
|
||||
if (!TCOD_dijkstra_path_set(tcod_dijkstra, x, y)) {
|
||||
if (valid) *valid = false;
|
||||
return sf::Vector2i(-1, -1);
|
||||
}
|
||||
|
||||
int px, py;
|
||||
if (TCOD_dijkstra_path_walk(tcod_dijkstra, &px, &py)) {
|
||||
// #375: the first cell of the corrected path -- guaranteed adjacent to (x, y).
|
||||
std::vector<sf::Vector2i> path = getPathFrom(x, y);
|
||||
if (!path.empty()) {
|
||||
if (valid) *valid = true;
|
||||
return sf::Vector2i(px, py);
|
||||
return path.front();
|
||||
}
|
||||
|
||||
if (valid) *valid = false;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ UIGridView::UIGridView()
|
|||
}
|
||||
|
||||
UIGridView::~UIGridView() {
|
||||
// #373: drop the Python-identity pins of our overlay children before the vector dies
|
||||
UIDrawable::releaseChildPins(children);
|
||||
|
||||
// #362: drop ourselves from the GridData's view registry. This is the only
|
||||
// place guaranteed to run exactly once per view: tp_dealloc's unregister is
|
||||
// gated on data.use_count() <= 1 (the #251 pattern), so a view that outlives
|
||||
|
|
@ -289,16 +292,33 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
|
|||
int y_limit = top_edge + height_sq + 2;
|
||||
if (y_limit > grid_data->grid_h) y_limit = grid_data->grid_h;
|
||||
|
||||
// #341: re-instrument the grid render counters. These were declared and reset
|
||||
// every frame but never incremented anywhere -- the increments were lost in the
|
||||
// GridView/chunk refactor, so grid_cells_rendered/entities_rendered/
|
||||
// grid_render_time have read 0 ever since. Accumulating (not assigning) so that
|
||||
// two views over one map sum rather than clobber each other.
|
||||
auto& metrics = Resources::game->metrics;
|
||||
ScopedAccumTimer gridTimer(metrics.gridRenderTime);
|
||||
|
||||
// Cells actually inside the viewport window, per layer drawn.
|
||||
const int x_start = std::max(0, static_cast<int>(left_edge));
|
||||
const int y_start = std::max(0, static_cast<int>(top_edge));
|
||||
const int visible_cells = std::max(0, x_limit - x_start) * std::max(0, y_limit - y_start);
|
||||
|
||||
// Render layers below entities (z_index <= 0)
|
||||
grid_data->sortLayers();
|
||||
int layers_drawn = 0;
|
||||
for (auto& layer : grid_data->layers) {
|
||||
if (layer->z_index > 0) break; // #257: z_index=0 is ground level (below entities)
|
||||
layer->render(*activeTexture, left_spritepixels, top_spritepixels,
|
||||
left_edge, top_edge, x_limit, y_limit, zoom, cell_width, cell_height);
|
||||
++layers_drawn;
|
||||
}
|
||||
|
||||
// Render entities
|
||||
if (grid_data->entities) {
|
||||
ScopedAccumTimer entityTimer(metrics.entityRenderTime);
|
||||
metrics.totalEntities += static_cast<int>(grid_data->entities->size());
|
||||
for (auto& e : *grid_data->entities) {
|
||||
if (e->position.x < left_edge - 2 || e->position.x >= left_edge + width_sq + 2 ||
|
||||
e->position.y < top_edge - 2 || e->position.y >= top_edge + height_sq + 2) {
|
||||
|
|
@ -310,6 +330,7 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
|
|||
(e->position.x*cell_width - left_spritepixels + e->sprite_offset.x) * zoom,
|
||||
(e->position.y*cell_height - top_spritepixels + e->sprite_offset.y) * zoom);
|
||||
drawent.render(pixel_pos, *activeTexture);
|
||||
++metrics.entitiesRendered;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -318,8 +339,12 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
|
|||
if (layer->z_index <= 0) continue; // #257: skip ground-level and below
|
||||
layer->render(*activeTexture, left_spritepixels, top_spritepixels,
|
||||
left_edge, top_edge, x_limit, y_limit, zoom, cell_width, cell_height);
|
||||
++layers_drawn;
|
||||
}
|
||||
|
||||
// One "cell rendered" per cell per layer drawn -- i.e. cell draw operations.
|
||||
metrics.gridCellsRendered += visible_cells * layers_drawn;
|
||||
|
||||
// Children (grid-world pixel coordinates; owned by this view -- #364)
|
||||
if (children && !children->empty()) {
|
||||
if (children_need_sort) {
|
||||
|
|
@ -343,7 +368,9 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
|
|||
|
||||
// Perspective overlay (#355: state owned by the view -- see get/set_perspective)
|
||||
if (perspective_enabled) {
|
||||
ScopedTimer fovTimer(Resources::game->metrics.fovOverlayTime);
|
||||
// #341: accumulate -- with two perspective views on screen, assignment made
|
||||
// the second view's time silently replace the first's.
|
||||
ScopedAccumTimer fovTimer(Resources::game->metrics.fovOverlayTime);
|
||||
auto entity = perspective_entity.lock();
|
||||
sf::RectangleShape overlay;
|
||||
overlay.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom));
|
||||
|
|
|
|||
32
src/main.cpp
32
src/main.cpp
|
|
@ -215,9 +215,37 @@ int run_python_interpreter(const McRogueFaceConfig& config)
|
|||
else if (!config.exec_scripts.empty()) {
|
||||
// Execute startup scripts on the existing engine (not in constructor to prevent double-execution)
|
||||
engine->executeStartupScripts();
|
||||
if (config.headless) {
|
||||
engine->setAutoExitAfterExec(true);
|
||||
|
||||
// A script that called sys.exit() has already decided the outcome.
|
||||
if (McRFPy_API::shouldExit()) {
|
||||
int code = McRFPy_API::exit_code.load();
|
||||
McRFPy_API::api_shutdown();
|
||||
delete engine;
|
||||
return code;
|
||||
}
|
||||
|
||||
// #350: headless --exec that falls off the end without exiting is an error.
|
||||
//
|
||||
// step() is the only headless clock, so the run loop cannot advance timers on
|
||||
// its own: entering it here used to spin forever with a frozen clock, and any
|
||||
// headless script with a pending timer hung until it was killed. Worse, a
|
||||
// script that died during setup registered no timers, auto-exited 0, and was
|
||||
// scored as a passing test.
|
||||
//
|
||||
// An --exec script must state its outcome with sys.exit(). If a long-lived
|
||||
// headless process is genuinely wanted (a server, a REPL host), say so with
|
||||
// --run-forever.
|
||||
if (config.headless && !config.run_forever) {
|
||||
std::cerr << "mcrogueface: --exec scripts finished without calling sys.exit().\n"
|
||||
<< " In headless mode the engine cannot advance time on its own: "
|
||||
<< "mcrfpy.step() is the clock.\n"
|
||||
<< " End the script with sys.exit(0), or pass --run-forever to keep "
|
||||
<< "the process alive.\n";
|
||||
McRFPy_API::api_shutdown();
|
||||
delete engine;
|
||||
return 1;
|
||||
}
|
||||
|
||||
engine->run();
|
||||
McRFPy_API::api_shutdown();
|
||||
delete engine;
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ Run with: xvfb-run -a ./build/mcrogueface --headless --exec tests/demo/cookbook_
|
|||
In headless mode, automation.screenshot() is SYNCHRONOUS - no timer dance needed!
|
||||
"""
|
||||
import mcrfpy
|
||||
import docs_output
|
||||
from mcrfpy import automation
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Output directory - in the docs site images folder
|
||||
OUTPUT_DIR = "/opt/goblincorps/repos/mcrogueface.github.io/images/cookbook"
|
||||
OUTPUT_DIR = docs_output.image_dir("cookbook") # #372: was a hardcoded absolute path
|
||||
|
||||
# Tile sprites from the labeled tileset
|
||||
TILES = {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ class DemoRunner:
|
|||
|
||||
def __init__(self):
|
||||
self.screens = []
|
||||
self.menu = None
|
||||
self.current_index = 0
|
||||
self.headless = self._detect_headless()
|
||||
self.screenshot_dir = os.path.join(os.path.dirname(__file__), "screenshots")
|
||||
|
|
@ -65,7 +66,10 @@ class DemoRunner:
|
|||
|
||||
def create_menu(self):
|
||||
"""Create the main menu screen."""
|
||||
menu = mcrfpy.Scene("menu")
|
||||
# #372: was a local, but run_interactive() referenced `menu` -- NameError on
|
||||
# every interactive launch. Keep it on the runner.
|
||||
self.menu = mcrfpy.Scene("menu")
|
||||
menu = self.menu
|
||||
ui = menu.children
|
||||
|
||||
# Title
|
||||
|
|
@ -104,46 +108,30 @@ class DemoRunner:
|
|||
ui.append(instr)
|
||||
|
||||
def run_headless(self):
|
||||
"""Run in headless mode - generate all screenshots."""
|
||||
print(f"Generating {len(self.screens)} demo screenshots...")
|
||||
"""Run in headless mode - generate all screenshots.
|
||||
|
||||
# Ensure screenshot directory exists
|
||||
#372/#350: this used to drive a Timer and wait for render frames. Headless
|
||||
timers only advance under mcrfpy.step(), so that loop never fired. It is also
|
||||
unnecessary: a screenshot renders the current state synchronously and costs
|
||||
zero simulation time, so we can just activate each scene and shoot it.
|
||||
"""
|
||||
print(f"Generating {len(self.screens)} demo screenshots...")
|
||||
os.makedirs(self.screenshot_dir, exist_ok=True)
|
||||
|
||||
# Use timer to take screenshots after game loop renders each scene
|
||||
self.current_index = 0
|
||||
self.render_wait = 0
|
||||
for i, screen in enumerate(self.screens):
|
||||
# #372: was `mcrfpy.current_scene = screen` -- a DemoScreen, not a Scene,
|
||||
# which raised TypeError and broke the headless path outright.
|
||||
screen.scene.activate()
|
||||
filename = os.path.join(self.screenshot_dir, screen.get_screenshot_name())
|
||||
automation.screenshot(filename)
|
||||
print(f" [{i+1}/{len(self.screens)}] {filename}")
|
||||
|
||||
def screenshot_cycle(timer, runtime):
|
||||
if self.render_wait == 0:
|
||||
# Set scene and wait for render
|
||||
if self.current_index >= len(self.screens):
|
||||
print("Done!")
|
||||
sys.exit(0)
|
||||
return
|
||||
screen = self.screens[self.current_index]
|
||||
mcrfpy.current_scene = screen
|
||||
self.render_wait = 1
|
||||
elif self.render_wait < 2:
|
||||
# Wait additional frame
|
||||
self.render_wait += 1
|
||||
else:
|
||||
# Take screenshot
|
||||
screen = self.screens[self.current_index]
|
||||
filename = os.path.join(self.screenshot_dir, screen.get_screenshot_name())
|
||||
automation.screenshot(filename)
|
||||
print(f" [{self.current_index+1}/{len(self.screens)}] {filename}")
|
||||
self.current_index += 1
|
||||
self.render_wait = 0
|
||||
if self.current_index >= len(self.screens):
|
||||
print("Done!")
|
||||
sys.exit(0)
|
||||
|
||||
self.screenshot_timer = mcrfpy.Timer("screenshot", screenshot_cycle, 50)
|
||||
print("Done!")
|
||||
|
||||
def run_interactive(self):
|
||||
"""Run in interactive mode with menu."""
|
||||
self.create_menu()
|
||||
menu = self.menu
|
||||
|
||||
def handle_key(key, state):
|
||||
if state != mcrfpy.InputState.PRESSED:
|
||||
|
|
@ -156,7 +144,8 @@ class DemoRunner:
|
|||
if key in _num_key_map:
|
||||
idx = _num_key_map[key]
|
||||
if idx < len(self.screens):
|
||||
mcrfpy.setScene(self.screens[idx].scene_name)
|
||||
# #372: mcrfpy.setScene() no longer exists.
|
||||
self.screens[idx].scene.activate()
|
||||
|
||||
# ESC returns to menu
|
||||
elif key == mcrfpy.Key.ESCAPE:
|
||||
|
|
@ -166,14 +155,10 @@ class DemoRunner:
|
|||
elif key == mcrfpy.Key.Q:
|
||||
sys.exit(0)
|
||||
|
||||
# Register keyboard handler on menu scene
|
||||
menu.activate()
|
||||
# The same handler serves the menu and every demo scene, so ESC always works.
|
||||
menu.on_key = handle_key
|
||||
|
||||
# Also register keyboard handler on all demo scenes
|
||||
for screen in self.screens:
|
||||
mcrfpy.current_scene = screen
|
||||
menu.on_key = handle_key
|
||||
screen.scene.on_key = handle_key
|
||||
|
||||
# Start on menu
|
||||
menu.activate()
|
||||
|
|
@ -185,6 +170,8 @@ def main():
|
|||
|
||||
if runner.headless:
|
||||
runner.run_headless()
|
||||
# #350: a headless --exec script must declare its outcome.
|
||||
sys.exit(0)
|
||||
else:
|
||||
runner.run_interactive()
|
||||
|
||||
|
|
|
|||
40
tests/demo/docs_output.py
Normal file
40
tests/demo/docs_output.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Where the showcase scripts write their generated images (#372).
|
||||
|
||||
These scripts used to hardcode /opt/goblincorps/repos/mcrogueface.github.io -- an
|
||||
absolute path outside the repo, on one developer's machine. Anyone else running them
|
||||
got PermissionError, which is part of why they rotted unnoticed.
|
||||
|
||||
Resolution order:
|
||||
1. $MCRF_DOCS_REPO -- explicit checkout of mcrogueface.github.io
|
||||
2. ../mcrogueface.github.io -- the usual side-by-side checkout
|
||||
3. tests/demo/screenshots/ -- in-repo default, always writable
|
||||
"""
|
||||
import os
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_REPO_ROOT = os.path.dirname(os.path.dirname(_HERE))
|
||||
|
||||
|
||||
def docs_repo():
|
||||
"""Root of the documentation site checkout, or None if there isn't one."""
|
||||
env = os.environ.get("MCRF_DOCS_REPO")
|
||||
if env and os.path.isdir(env):
|
||||
return env
|
||||
sibling = os.path.join(os.path.dirname(_REPO_ROOT), "mcrogueface.github.io")
|
||||
if os.path.isdir(sibling):
|
||||
return sibling
|
||||
return None
|
||||
|
||||
|
||||
def image_dir(*parts):
|
||||
"""Directory for generated images, created if needed.
|
||||
|
||||
parts: subpath under the docs site's images/ (e.g. "cookbook", "tutorials").
|
||||
"""
|
||||
repo = docs_repo()
|
||||
if repo:
|
||||
target = os.path.join(repo, "images", *parts)
|
||||
else:
|
||||
target = os.path.join(_HERE, "screenshots", *parts)
|
||||
os.makedirs(target, exist_ok=True)
|
||||
return target
|
||||
|
|
@ -6,11 +6,12 @@ Generates screenshots for the new API cookbook recipes.
|
|||
Run with: xvfb-run -a ./build/mcrogueface --headless --exec tests/demo/new_features_showcase.py
|
||||
"""
|
||||
import mcrfpy
|
||||
import docs_output
|
||||
from mcrfpy import automation
|
||||
import sys
|
||||
import os
|
||||
|
||||
OUTPUT_DIR = "/opt/goblincorps/repos/mcrogueface.github.io/images/cookbook"
|
||||
OUTPUT_DIR = docs_output.image_dir("cookbook") # #372: was a hardcoded absolute path
|
||||
|
||||
|
||||
def screenshot_alignment():
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@
|
|||
Uses Frame-based visualization since Grid cell colors use ColorLayer API.
|
||||
"""
|
||||
import mcrfpy
|
||||
import os
|
||||
import docs_output
|
||||
from mcrfpy import automation
|
||||
import sys
|
||||
|
||||
OUTPUT_DIR = "/opt/goblincorps/repos/mcrogueface.github.io/images/cookbook"
|
||||
OUTPUT_DIR = docs_output.image_dir("cookbook") # #372: was a hardcoded absolute path
|
||||
|
||||
# Simple PRNG
|
||||
_seed = 42
|
||||
|
|
|
|||
|
|
@ -10,20 +10,21 @@ class AnimationDemo(DemoScreen):
|
|||
self.add_title("Animation System")
|
||||
self.add_description("Smooth property animation with multiple easing functions")
|
||||
|
||||
# Create frames to animate
|
||||
# Create frames to animate.
|
||||
# #372: easings are the mcrfpy.Easing enum now, not magic strings.
|
||||
easing_types = [
|
||||
("linear", mcrfpy.Color(255, 100, 100)),
|
||||
("easeIn", mcrfpy.Color(100, 255, 100)),
|
||||
("easeOut", mcrfpy.Color(100, 100, 255)),
|
||||
("easeInOut", mcrfpy.Color(255, 255, 100)),
|
||||
("linear", mcrfpy.Easing.LINEAR, mcrfpy.Color(255, 100, 100)),
|
||||
("easeIn", mcrfpy.Easing.EASE_IN, mcrfpy.Color(100, 255, 100)),
|
||||
("easeOut", mcrfpy.Easing.EASE_OUT, mcrfpy.Color(100, 100, 255)),
|
||||
("easeInOut", mcrfpy.Easing.EASE_IN_OUT, mcrfpy.Color(255, 255, 100)),
|
||||
]
|
||||
|
||||
self.frames = []
|
||||
for i, (easing, color) in enumerate(easing_types):
|
||||
for i, (easing_name, easing, color) in enumerate(easing_types):
|
||||
y = 140 + i * 60
|
||||
|
||||
# Label
|
||||
label = mcrfpy.Caption(text=easing, pos=(50, y + 5))
|
||||
label = mcrfpy.Caption(text=easing_name, pos=(50, y + 5))
|
||||
label.fill_color = mcrfpy.Color(200, 200, 200)
|
||||
self.ui.append(label)
|
||||
|
||||
|
|
@ -42,9 +43,9 @@ class AnimationDemo(DemoScreen):
|
|||
|
||||
# Start animations for each frame (they'll animate when viewed interactively)
|
||||
for frame, easing in self.frames:
|
||||
# Animate x to 560 over 2 seconds (starts from current x=150)
|
||||
anim = mcrfpy.Animation("x", 560.0, 2.0, easing)
|
||||
anim.start(frame)
|
||||
# #372: mcrfpy.Animation is no longer exported -- animations are constructed
|
||||
# by the target itself. Animate x to 560 over 2s (starts from current x=150).
|
||||
frame.animate("x", 560.0, 2.0, easing)
|
||||
|
||||
# Property animations section
|
||||
prop_frame = mcrfpy.Frame(pos=(50, 400), size=(300, 100))
|
||||
|
|
@ -66,7 +67,7 @@ class AnimationDemo(DemoScreen):
|
|||
prop_frame.children.append(props_line2)
|
||||
|
||||
# Code example - positioned below other elements
|
||||
code = """# Animation: (property, target, duration, easing)
|
||||
anim = mcrfpy.Animation("x", 500.0, 2.0, "easeInOut")
|
||||
anim.start(frame) # Animate frame.x to 500 over 2 seconds"""
|
||||
code = """# Animate via the target: (property, target, duration, easing)
|
||||
frame.animate("x", 500.0, 2.0, mcrfpy.Easing.EASE_IN_OUT)
|
||||
# -> frame.x eases to 500 over 2 seconds"""
|
||||
self.add_code_example(code, x=50, y=520)
|
||||
|
|
|
|||
|
|
@ -9,8 +9,11 @@ class DemoScreen:
|
|||
|
||||
def __init__(self, scene_name):
|
||||
self.scene_name = scene_name
|
||||
_scene = mcrfpy.Scene(scene_name)
|
||||
self.ui = mcrfpy.sceneUI(scene_name)
|
||||
# #372: mcrfpy.sceneUI() was removed with the pre-Scene-object API. The Scene
|
||||
# owns its UI collection directly, and the demos need the Scene anyway to
|
||||
# activate themselves.
|
||||
self.scene = mcrfpy.Scene(scene_name)
|
||||
self.ui = self.scene.children
|
||||
|
||||
def setup(self):
|
||||
"""Override to set up the screen content."""
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ class GridDemo(DemoScreen):
|
|||
grid.fill_color = mcrfpy.Color(20, 20, 40)
|
||||
|
||||
# Add a color layer for the checkerboard pattern (z_index=-1 = below entities)
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
# #372: add_layer() takes a layer OBJECT and no keyword arguments.
|
||||
color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
|
||||
|
||||
# Center camera on middle of grid (in pixel coordinates: cells * cell_size / 2)
|
||||
# For 15x10 grid with 16x16 cells: center = (15*16/2, 10*16/2) = (120, 80)
|
||||
|
|
@ -28,13 +29,13 @@ class GridDemo(DemoScreen):
|
|||
point = grid.at(x, y)
|
||||
# Checkerboard pattern
|
||||
if (x + y) % 2 == 0:
|
||||
color_layer.set(x, y, mcrfpy.Color(40, 40, 60))
|
||||
color_layer.set((x, y), mcrfpy.Color(40, 40, 60))
|
||||
else:
|
||||
color_layer.set(x, y, mcrfpy.Color(30, 30, 50))
|
||||
color_layer.set((x, y), mcrfpy.Color(30, 30, 50))
|
||||
|
||||
# Border
|
||||
if x == 0 or x == 14 or y == 0 or y == 9:
|
||||
color_layer.set(x, y, mcrfpy.Color(80, 60, 40))
|
||||
color_layer.set((x, y), mcrfpy.Color(80, 60, 40))
|
||||
point.walkable = False
|
||||
|
||||
# Add some children to the grid
|
||||
|
|
@ -74,7 +75,7 @@ class GridDemo(DemoScreen):
|
|||
# Code example
|
||||
code = """# Grid with layers
|
||||
grid = mcrfpy.Grid(grid_size=(20, 15), pos=(50, 50), size=(320, 240), layers={})
|
||||
layer = grid.add_layer("color", z_index=-1) # Below entities
|
||||
layer.set(5, 5, mcrfpy.Color(255, 0, 0)) # Red tile
|
||||
layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1)) # Below entities
|
||||
layer.set((5, 5), mcrfpy.Color(255, 0, 0)) # Red tile
|
||||
grid.children.append(mcrfpy.Caption("Label", pos=(80, 48)))"""
|
||||
self.add_code_example(code, y=420)
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
|
@ -9,12 +9,13 @@ NOTE: In headless mode, automation.screenshot() is SYNCHRONOUS - it renders
|
|||
and captures immediately. No timer dance needed!
|
||||
"""
|
||||
import mcrfpy
|
||||
import docs_output
|
||||
from mcrfpy import automation
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Output
|
||||
OUTPUT_PATH = "/opt/goblincorps/repos/mcrogueface.github.io/images/tutorials/part_01_grid_movement.png"
|
||||
OUTPUT_PATH = os.path.join(docs_output.image_dir("tutorials"), "part_01_grid_movement.png") # #372
|
||||
|
||||
# Tile sprites from the labeled tileset
|
||||
PLAYER_KNIGHT = 84
|
||||
|
|
|
|||
|
|
@ -8,13 +8,14 @@ Usage:
|
|||
Extracts code from tutorial markdown files and generates screenshots.
|
||||
"""
|
||||
import mcrfpy
|
||||
import docs_output
|
||||
from mcrfpy import automation
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
|
||||
# Paths
|
||||
DOCS_REPO = "/opt/goblincorps/repos/mcrogueface.github.io"
|
||||
DOCS_REPO = docs_output.docs_repo() or os.path.dirname(os.path.abspath(__file__)) # #372
|
||||
TUTORIAL_DIR = os.path.join(DOCS_REPO, "tutorial")
|
||||
OUTPUT_DIR = os.path.join(DOCS_REPO, "images", "tutorials")
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ Run with: xvfb-run -a ./build/mcrogueface --headless --exec tests/demo/tutorial_
|
|||
In headless mode, automation.screenshot() is SYNCHRONOUS - no timer dance needed!
|
||||
"""
|
||||
import mcrfpy
|
||||
import docs_output
|
||||
from mcrfpy import automation
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Output directory
|
||||
OUTPUT_DIR = "/opt/goblincorps/repos/mcrogueface.github.io/images/tutorials"
|
||||
OUTPUT_DIR = docs_output.image_dir("tutorials") # #372: was a hardcoded absolute path
|
||||
|
||||
# Tile meanings from the labeled tileset - the FUN sprites!
|
||||
TILES = {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
A* vs Dijkstra Visual Comparison
|
||||
=================================
|
||||
A* vs Dijkstra Comparison
|
||||
=========================
|
||||
|
||||
Shows the difference between A* (single target) and Dijkstra (multi-target).
|
||||
Verifies that A* (single target) and Dijkstra (multi-target flood) agree on the
|
||||
cost of the shortest route through an obstacle course, that both produce legal,
|
||||
contiguous, walkable paths, and that the Dijkstra distance field is consistent
|
||||
with the paths it hands back.
|
||||
|
||||
API notes (updated for the current engine):
|
||||
* Pathfinding lives on GridData: grid.find_path(start, end) -> AStarPath|None,
|
||||
grid.get_dijkstra_map(root=...) -> DijkstraMap (.distance(pos), .path_from(pos)).
|
||||
The old grid.compute_astar_path / compute_dijkstra / get_dijkstra_path are gone.
|
||||
* GridPoint has no .color -- cell coloring is done with a ColorLayer.
|
||||
* add_layer() takes a layer OBJECT and no keyword arguments.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
|
|
@ -17,31 +27,41 @@ DIJKSTRA_COLOR = mcrfpy.Color(0, 150, 255) # Blue for Dijkstra
|
|||
START_COLOR = mcrfpy.Color(255, 100, 100) # Red for start
|
||||
END_COLOR = mcrfpy.Color(255, 255, 100) # Yellow for end
|
||||
|
||||
GRID_W, GRID_H = 30, 20
|
||||
|
||||
# Global state
|
||||
grid = None
|
||||
color_layer = None
|
||||
mode = "ASTAR"
|
||||
start_pos = (5, 10)
|
||||
end_pos = (27, 10) # Changed from 25 to 27 to avoid the wall
|
||||
|
||||
failures = []
|
||||
|
||||
def check(condition, message):
|
||||
"""Record a failed assertion instead of aborting, so we report every problem."""
|
||||
if condition:
|
||||
print(f" PASS: {message}")
|
||||
else:
|
||||
print(f" FAIL: {message}")
|
||||
failures.append(message)
|
||||
|
||||
def create_map():
|
||||
"""Create a map with obstacles to show pathfinding differences"""
|
||||
global grid, color_layer
|
||||
|
||||
pathfinding_comparison = mcrfpy.Scene("pathfinding_comparison")
|
||||
|
||||
# Create grid
|
||||
grid = mcrfpy.Grid(grid_w=30, grid_h=20)
|
||||
# Create grid (view + backing GridData)
|
||||
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H), pos=(100, 100), size=(600, 400))
|
||||
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||
|
||||
# Add color layer for cell coloring
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
# Add color layer for cell coloring (GridPoint.color no longer exists)
|
||||
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||
grid.grid_data.add_layer(color_layer)
|
||||
|
||||
# Initialize all as floor
|
||||
for y in range(20):
|
||||
for x in range(30):
|
||||
grid.at(x, y).walkable = True
|
||||
color_layer.set(x, y, FLOOR_COLOR)
|
||||
for y in range(GRID_H):
|
||||
for x in range(GRID_W):
|
||||
grid.grid_data.at(x, y).walkable = True
|
||||
color_layer.set((x, y), FLOOR_COLOR)
|
||||
|
||||
# Create obstacles that make A* and Dijkstra differ
|
||||
obstacles = [
|
||||
|
|
@ -55,172 +75,179 @@ def create_map():
|
|||
[(25, y) for y in range(5, 15)],
|
||||
]
|
||||
|
||||
walls = set()
|
||||
for obstacle_group in obstacles:
|
||||
for x, y in obstacle_group:
|
||||
grid.at(x, y).walkable = False
|
||||
color_layer.set(x, y, WALL_COLOR)
|
||||
grid.grid_data.at(x, y).walkable = False
|
||||
color_layer.set((x, y), WALL_COLOR)
|
||||
walls.add((x, y))
|
||||
|
||||
# Mark start and end
|
||||
color_layer.set(start_pos[0], start_pos[1], START_COLOR)
|
||||
color_layer.set(end_pos[0], end_pos[1], END_COLOR)
|
||||
color_layer.set(start_pos, START_COLOR)
|
||||
color_layer.set(end_pos, END_COLOR)
|
||||
|
||||
def clear_paths():
|
||||
"""Clear path highlighting"""
|
||||
for y in range(20):
|
||||
for x in range(30):
|
||||
cell = grid.at(x, y)
|
||||
if cell.walkable:
|
||||
color_layer.set(x, y, FLOOR_COLOR)
|
||||
# Dijkstra maps are cached; walkability just changed, so invalidate.
|
||||
grid.grid_data.clear_dijkstra_maps()
|
||||
return walls
|
||||
|
||||
# Restore start and end colors
|
||||
color_layer.set(start_pos[0], start_pos[1], START_COLOR)
|
||||
color_layer.set(end_pos[0], end_pos[1], END_COLOR)
|
||||
def validate_path(cells, label, origin, destination):
|
||||
"""A path must be contiguous, walkable, and actually arrive at `destination`.
|
||||
|
||||
Both find_path() and DijkstraMap.path_from() use the same convention (#375):
|
||||
the returned path EXCLUDES the origin and ENDS at the destination. The two
|
||||
differ only in which endpoint is which -- A* is asked start->end, while a
|
||||
Dijkstra map rooted at start is queried from end, so it walks end->start.
|
||||
"""
|
||||
check(len(cells) > 0, f"{label}: path is non-empty")
|
||||
if not cells:
|
||||
return
|
||||
|
||||
check(cells[-1] == destination,
|
||||
f"{label}: path ends at {destination} (got {cells[-1]})")
|
||||
check(origin not in cells, f"{label}: path excludes its origin {origin}")
|
||||
|
||||
# The first cell must be a single step from the origin.
|
||||
prev = origin
|
||||
contiguous = True
|
||||
walkable = True
|
||||
for (x, y) in cells:
|
||||
dx, dy = abs(x - prev[0]), abs(y - prev[1])
|
||||
if max(dx, dy) != 1:
|
||||
contiguous = False
|
||||
if not grid.grid_data.at(x, y).walkable:
|
||||
walkable = False
|
||||
prev = (x, y)
|
||||
check(contiguous, f"{label}: every step moves exactly one cell (incl. from start)")
|
||||
check(walkable, f"{label}: every cell on the path is walkable")
|
||||
|
||||
def path_cells(path):
|
||||
return [(int(v.x), int(v.y)) for v in path]
|
||||
|
||||
def show_astar():
|
||||
"""Show A* path"""
|
||||
clear_paths()
|
||||
"""Compute + color the A* path. Returns its cells."""
|
||||
path = grid.grid_data.find_path(start_pos, end_pos)
|
||||
check(path is not None, "A*: a path exists through the obstacle course")
|
||||
if path is None:
|
||||
return []
|
||||
|
||||
# Compute A* path
|
||||
path = grid.compute_astar_path(start_pos[0], start_pos[1], end_pos[0], end_pos[1])
|
||||
check((int(path.destination.x), int(path.destination.y)) == end_pos,
|
||||
"A*: .destination is the requested end cell")
|
||||
|
||||
# Color the path
|
||||
for i, (x, y) in enumerate(path):
|
||||
# NOTE: AStarPath is a *consuming* iterator -- iterating it walks the path,
|
||||
# so .remaining must be read before draining it.
|
||||
expected = path.remaining
|
||||
cells = path_cells(path)
|
||||
check(expected == len(cells),
|
||||
f"A*: .remaining ({expected}) matches the iterated length ({len(cells)})")
|
||||
check(path.remaining == 0, "A*: iterating the path consumes it (.remaining -> 0)")
|
||||
|
||||
for (x, y) in cells:
|
||||
if (x, y) != start_pos and (x, y) != end_pos:
|
||||
color_layer.set(x, y, ASTAR_COLOR)
|
||||
color_layer.set((x, y), ASTAR_COLOR)
|
||||
|
||||
status_text.text = f"A* Path: {len(path)} steps (optimized for single target)"
|
||||
status_text.text = f"A* Path: {len(cells)} steps (optimized for single target)"
|
||||
status_text.fill_color = ASTAR_COLOR
|
||||
return cells
|
||||
|
||||
def show_dijkstra():
|
||||
"""Show Dijkstra exploration"""
|
||||
clear_paths()
|
||||
def show_dijkstra(walls):
|
||||
"""Compute the Dijkstra flood from start, color it, return the path cells."""
|
||||
dmap = grid.grid_data.get_dijkstra_map(root=start_pos)
|
||||
check((int(dmap.root.x), int(dmap.root.y)) == start_pos,
|
||||
"Dijkstra: map root is the start cell")
|
||||
|
||||
# Compute Dijkstra from start
|
||||
grid.compute_dijkstra(start_pos[0], start_pos[1])
|
||||
# The distance field must be defined on reachable floor and undefined on walls.
|
||||
check(dmap.distance(start_pos) == 0.0, "Dijkstra: distance to the root itself is 0")
|
||||
end_dist = dmap.distance(end_pos)
|
||||
check(end_dist is not None and end_dist > 0,
|
||||
f"Dijkstra: end cell is reachable (distance={end_dist})")
|
||||
|
||||
wall_distances_defined = [w for w in walls if dmap.distance(w) is not None]
|
||||
check(not wall_distances_defined,
|
||||
f"Dijkstra: unwalkable cells have no distance ({len(wall_distances_defined)} leaked)")
|
||||
|
||||
# Color cells by distance (showing exploration)
|
||||
max_dist = 40.0
|
||||
for y in range(20):
|
||||
for x in range(30):
|
||||
if grid.at(x, y).walkable:
|
||||
dist = grid.get_dijkstra_distance(x, y)
|
||||
for y in range(GRID_H):
|
||||
for x in range(GRID_W):
|
||||
if grid.grid_data.at(x, y).walkable:
|
||||
dist = dmap.distance((x, y))
|
||||
if dist is not None and dist < max_dist:
|
||||
# Color based on distance
|
||||
intensity = int(255 * (1 - dist / max_dist))
|
||||
color_layer.set(x, y, mcrfpy.Color(0, intensity // 2, intensity))
|
||||
color_layer.set((x, y), mcrfpy.Color(0, intensity // 2, intensity))
|
||||
|
||||
# Get the actual path
|
||||
path = grid.get_dijkstra_path(end_pos[0], end_pos[1])
|
||||
# Get the actual path back to the root
|
||||
cells = path_cells(dmap.path_from(end_pos))
|
||||
|
||||
# Highlight the actual path more brightly
|
||||
for x, y in path:
|
||||
for (x, y) in cells:
|
||||
if (x, y) != start_pos and (x, y) != end_pos:
|
||||
color_layer.set(x, y, DIJKSTRA_COLOR)
|
||||
color_layer.set((x, y), DIJKSTRA_COLOR)
|
||||
|
||||
# Restore start and end
|
||||
color_layer.set(start_pos[0], start_pos[1], START_COLOR)
|
||||
color_layer.set(end_pos[0], end_pos[1], END_COLOR)
|
||||
color_layer.set(start_pos, START_COLOR)
|
||||
color_layer.set(end_pos, END_COLOR)
|
||||
|
||||
status_text.text = f"Dijkstra: {len(path)} steps (explores all directions)"
|
||||
status_text.text = f"Dijkstra: {len(cells)} steps (explores all directions)"
|
||||
status_text.fill_color = DIJKSTRA_COLOR
|
||||
return cells, dmap
|
||||
|
||||
def show_both():
|
||||
"""Show both paths overlaid"""
|
||||
clear_paths()
|
||||
def show_both(astar_cells, dijkstra_cells, dmap):
|
||||
"""Compare the two routes. Different cells are fine; different COST is not."""
|
||||
print(f" A* : {astar_cells}")
|
||||
print(f" Dijkstra: {dijkstra_cells}")
|
||||
|
||||
# Get both paths
|
||||
astar_path = grid.compute_astar_path(start_pos[0], start_pos[1], end_pos[0], end_pos[1])
|
||||
grid.compute_dijkstra(start_pos[0], start_pos[1])
|
||||
dijkstra_path = grid.get_dijkstra_path(end_pos[0], end_pos[1])
|
||||
# Both algorithms are optimal, so they must agree on the number of steps
|
||||
# even if they break ties through different cells.
|
||||
check(len(astar_cells) == len(dijkstra_cells),
|
||||
f"A* and Dijkstra agree on path length (A*={len(astar_cells)}, "
|
||||
f"Dijkstra={len(dijkstra_cells)})")
|
||||
|
||||
print(astar_path, dijkstra_path)
|
||||
# The Dijkstra distance field must be monotonically increasing along the
|
||||
# A* route - i.e. the flood is consistent with the single-target search.
|
||||
monotonic = True
|
||||
prev = dmap.distance(start_pos)
|
||||
for cell in astar_cells:
|
||||
d = dmap.distance(cell)
|
||||
if d is None or d <= prev:
|
||||
monotonic = False
|
||||
break
|
||||
prev = d
|
||||
check(monotonic, "Dijkstra distance increases at every step along the A* path")
|
||||
|
||||
# Color Dijkstra path first (blue)
|
||||
for x, y in dijkstra_path:
|
||||
if (x, y) != start_pos and (x, y) != end_pos:
|
||||
color_layer.set(x, y, DIJKSTRA_COLOR)
|
||||
different_cells = [c for c in dijkstra_cells if c not in astar_cells]
|
||||
|
||||
# Then A* path (green) - will overwrite shared cells
|
||||
for x, y in astar_path:
|
||||
if (x, y) != start_pos and (x, y) != end_pos:
|
||||
color_layer.set(x, y, ASTAR_COLOR)
|
||||
|
||||
# Mark differences
|
||||
different_cells = []
|
||||
for cell in dijkstra_path:
|
||||
if cell not in astar_path:
|
||||
different_cells.append(cell)
|
||||
|
||||
status_text.text = f"Both paths: A*={len(astar_path)} steps, Dijkstra={len(dijkstra_path)} steps"
|
||||
status_text.text = (f"Both paths: A*={len(astar_cells)} steps, "
|
||||
f"Dijkstra={len(dijkstra_cells)} steps")
|
||||
if different_cells:
|
||||
info_text.text = f"Paths differ at {len(different_cells)} cells"
|
||||
else:
|
||||
info_text.text = "Paths are identical"
|
||||
print(f" {info_text.text}")
|
||||
|
||||
def handle_keypress(key_str, state):
|
||||
"""Handle keyboard input"""
|
||||
global mode
|
||||
if state == mcrfpy.InputState.RELEASED: return
|
||||
print(key_str)
|
||||
if key_str == mcrfpy.Key.ESCAPE or key_str == mcrfpy.Key.Q:
|
||||
print("\nExiting...")
|
||||
sys.exit(0)
|
||||
elif key_str == mcrfpy.Key.A or key_str == mcrfpy.Key.NUM_1:
|
||||
mode = "ASTAR"
|
||||
show_astar()
|
||||
elif key_str == mcrfpy.Key.D or key_str == mcrfpy.Key.NUM_2:
|
||||
mode = "DIJKSTRA"
|
||||
show_dijkstra()
|
||||
elif key_str == mcrfpy.Key.B or key_str == mcrfpy.Key.NUM_3:
|
||||
mode = "BOTH"
|
||||
show_both()
|
||||
elif key_str == mcrfpy.Key.SPACE:
|
||||
# Refresh current mode
|
||||
if mode == "ASTAR":
|
||||
show_astar()
|
||||
elif mode == "DIJKSTRA":
|
||||
show_dijkstra()
|
||||
else:
|
||||
show_both()
|
||||
|
||||
# Create the demo
|
||||
# ---------------------------------------------------------------------------
|
||||
print("A* vs Dijkstra Pathfinding Comparison")
|
||||
print("=====================================")
|
||||
print("Controls:")
|
||||
print(" A or 1 - Show A* path (green)")
|
||||
print(" D or 2 - Show Dijkstra (blue gradient)")
|
||||
print(" B or 3 - Show both paths")
|
||||
print(" Q/ESC - Quit")
|
||||
print()
|
||||
print("A* is optimized for single-target pathfinding")
|
||||
print("Dijkstra explores in all directions (good for multiple targets)")
|
||||
print()
|
||||
|
||||
create_map()
|
||||
walls = create_map()
|
||||
|
||||
# Set up UI
|
||||
pathfinding_comparison = mcrfpy.Scene("pathfinding_comparison")
|
||||
ui = pathfinding_comparison.children
|
||||
ui.append(grid)
|
||||
|
||||
# Scale and position
|
||||
grid.size = (600, 400) # 30*20, 20*20
|
||||
grid.pos = (100, 100)
|
||||
|
||||
# Add title
|
||||
title = mcrfpy.Caption(pos=(250, 20), text="A* vs Dijkstra Pathfinding")
|
||||
title.fill_color = mcrfpy.Color(255, 255, 255)
|
||||
ui.append(title)
|
||||
|
||||
# Add status
|
||||
status_text = mcrfpy.Caption(pos=(100, 60), text="Press A for A*, D for Dijkstra, B for Both")
|
||||
status_text = mcrfpy.Caption(pos=(100, 60), text="Comparing A* and Dijkstra")
|
||||
status_text.fill_color = mcrfpy.Color(200, 200, 200)
|
||||
ui.append(status_text)
|
||||
|
||||
# Add info
|
||||
info_text = mcrfpy.Caption(pos=(100, 520), text="")
|
||||
info_text.fill_color = mcrfpy.Color(200, 200, 200)
|
||||
ui.append(info_text)
|
||||
|
||||
# Add legend
|
||||
legend1 = mcrfpy.Caption(pos=(100, 540), text="Red=Start, Yellow=End, Green=A*, Blue=Dijkstra")
|
||||
legend1.fill_color = mcrfpy.Color(150, 150, 150)
|
||||
ui.append(legend1)
|
||||
|
|
@ -229,11 +256,38 @@ legend2 = mcrfpy.Caption(pos=(100, 560), text="Dark=Walls, Light=Floor")
|
|||
legend2.fill_color = mcrfpy.Color(150, 150, 150)
|
||||
ui.append(legend2)
|
||||
|
||||
# Set scene and input
|
||||
pathfinding_comparison.activate()
|
||||
pathfinding_comparison.on_key = handle_keypress
|
||||
|
||||
# Show initial A* path
|
||||
show_astar()
|
||||
# Sanity: the ColorLayer really is the coloring mechanism now.
|
||||
color_layer.set((0, 0), START_COLOR)
|
||||
_c = color_layer.at((0, 0))
|
||||
check((_c.r, _c.g, _c.b) == (255, 100, 100),
|
||||
"ColorLayer.set/.at round-trips a cell color")
|
||||
color_layer.set((0, 0), FLOOR_COLOR)
|
||||
|
||||
print("\nDemo ready!")
|
||||
print("\n[A*]")
|
||||
astar_cells = show_astar()
|
||||
# A* is asked start -> end.
|
||||
validate_path(astar_cells, "A*", origin=start_pos, destination=end_pos)
|
||||
|
||||
print("\n[Dijkstra]")
|
||||
dijkstra_cells, dmap = show_dijkstra(walls)
|
||||
# The Dijkstra map is ROOTED at start_pos and queried FROM end_pos, so its path
|
||||
# runs end -> start: the origin and destination are the mirror of A*'s.
|
||||
validate_path(dijkstra_cells, "Dijkstra", origin=end_pos, destination=start_pos)
|
||||
|
||||
print("\n[Both]")
|
||||
show_both(astar_cells, dijkstra_cells, dmap)
|
||||
|
||||
# Force a render so the colored comparison is actually drawn.
|
||||
mcrfpy.automation.screenshot("astar_vs_dijkstra.png")
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"FAIL: {len(failures)} check(s) failed:")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,30 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Debug visibility crash"""
|
||||
"""Debug visibility crash
|
||||
|
||||
Originally probed entity.gridstate / entity.update_visibility() / grid.perspective
|
||||
for crashes. Updated to the current API (#313/#361):
|
||||
- entity.gridstate -> entity.perspective_map (a 3-state DiscreteMap:
|
||||
UNKNOWN / DISCOVERED / VISIBLE, indexed by (x, y))
|
||||
- grid.perspective -> now takes an Entity or None, not an int index
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
failures = []
|
||||
|
||||
def check(cond, msg):
|
||||
if cond:
|
||||
print(f" ok: {msg}")
|
||||
else:
|
||||
print(f" FAIL: {msg}")
|
||||
failures.append(msg)
|
||||
|
||||
print("Debug visibility...")
|
||||
|
||||
# Create scene and grid
|
||||
debug = mcrfpy.Scene("debug")
|
||||
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
|
||||
grid = mcrfpy.Grid(grid_size=(5, 5))
|
||||
|
||||
# Initialize grid
|
||||
print("Initializing grid...")
|
||||
|
|
@ -20,39 +36,66 @@ for y in range(5):
|
|||
|
||||
# Create entity
|
||||
print("Creating entity...")
|
||||
entity = mcrfpy.Entity((2, 2), grid=grid)
|
||||
entity = mcrfpy.Entity(grid_pos=(2, 2))
|
||||
entity.sprite_index = 64
|
||||
print(f"Entity at ({entity.x}, {entity.y})")
|
||||
grid.entities.append(entity)
|
||||
# .x/.y are PIXEL coords now; .grid_x/.grid_y are the canonical cell coords
|
||||
print(f"Entity at cell ({entity.grid_x}, {entity.grid_y}), pixels ({entity.x}, {entity.y})")
|
||||
check((entity.grid_x, entity.grid_y) == (2, 2), "entity sits at its grid_pos (2, 2)")
|
||||
|
||||
# Check gridstate
|
||||
print(f"\nGridstate length: {len(entity.gridstate)}")
|
||||
print(f"Expected: {5 * 5}")
|
||||
# A second entity, so visible_entities() has something to find
|
||||
neighbor = mcrfpy.Entity(grid_pos=(4, 4))
|
||||
grid.entities.append(neighbor)
|
||||
|
||||
# Try to access gridstate
|
||||
print("\nChecking gridstate access...")
|
||||
try:
|
||||
if len(entity.gridstate) > 0:
|
||||
state = entity.gridstate[0]
|
||||
print(f"First state: visible={state.visible}, discovered={state.discovered}")
|
||||
except Exception as e:
|
||||
print(f"Error accessing gridstate: {e}")
|
||||
# Check perspective_map (successor to gridstate)
|
||||
pmap = entity.perspective_map
|
||||
print(f"\nPerspective map size: {pmap.size}")
|
||||
check(pmap.size == (5, 5), "perspective_map covers every grid cell (5x5)")
|
||||
|
||||
# Try update_visibility
|
||||
# Access the map before any FOV is computed: everything is unknown
|
||||
print("\nChecking perspective_map access...")
|
||||
Perspective = pmap.enum_type
|
||||
check(pmap.get((0, 0)) == Perspective.UNKNOWN,
|
||||
"cells start UNKNOWN before update_visibility()")
|
||||
check(pmap.histogram() == {int(Perspective.UNKNOWN): 25},
|
||||
"all 25 cells start UNKNOWN")
|
||||
|
||||
# update_visibility must populate the map (open 5x5 room, sight_radius 10)
|
||||
print("\nTrying update_visibility...")
|
||||
try:
|
||||
entity.update_visibility()
|
||||
print("update_visibility succeeded")
|
||||
except Exception as e:
|
||||
print(f"Error in update_visibility: {e}")
|
||||
entity.update_visibility()
|
||||
check(pmap.get((2, 2)) == Perspective.VISIBLE,
|
||||
"entity's own cell is VISIBLE after update_visibility()")
|
||||
check(pmap.get((0, 0)) == Perspective.VISIBLE,
|
||||
"far corner of an open, transparent room is VISIBLE")
|
||||
check(pmap.histogram() == {int(Perspective.VISIBLE): 25},
|
||||
"all 25 cells of the open room are VISIBLE")
|
||||
# visible_entities() returns OTHER entities only. Compare by cell, not identity:
|
||||
# it hands back fresh wrappers rather than cached ones (see notes on UIEntity.cpp:1334).
|
||||
seen = [(e.grid_x, e.grid_y) for e in entity.visible_entities()]
|
||||
check(seen == [(4, 4)],
|
||||
"visible_entities() sees the neighbor at (4, 4) and excludes self")
|
||||
|
||||
# Try perspective
|
||||
# Perspective now takes an Entity (or None), not an index
|
||||
print("\nTesting perspective...")
|
||||
print(f"Initial perspective: {grid.perspective}")
|
||||
check(grid.perspective is None, "grid.perspective defaults to None (omniscient)")
|
||||
|
||||
grid.perspective = entity
|
||||
print(f"Set perspective to entity: {grid.perspective}")
|
||||
check(grid.perspective is entity, "grid.perspective round-trips the Entity")
|
||||
|
||||
grid.perspective = None
|
||||
check(grid.perspective is None, "grid.perspective can be cleared back to None")
|
||||
|
||||
try:
|
||||
grid.perspective = 0
|
||||
print(f"Set perspective to 0: {grid.perspective}")
|
||||
except Exception as e:
|
||||
print(f"Error setting perspective: {e}")
|
||||
check(False, "grid.perspective rejects a non-Entity (int index is gone)")
|
||||
except TypeError:
|
||||
check(True, "grid.perspective rejects a non-Entity (int index is gone)")
|
||||
|
||||
print("\nTest complete")
|
||||
sys.exit(0)
|
||||
if failures:
|
||||
print(f"FAIL: {len(failures)} check(s) failed")
|
||||
sys.exit(1)
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ Dijkstra Demo - Shows ALL Path Combinations (Including Invalid)
|
|||
|
||||
Cycles through every possible entity pair to demonstrate both
|
||||
valid paths and properly handled invalid paths (empty lists).
|
||||
|
||||
Entity 1 is sealed inside a 2x2 pocket of walls, so every path to or from
|
||||
it must come back as an empty list; the two reachable entities must produce
|
||||
real, contiguous, walkable paths.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
|
|
@ -20,24 +24,35 @@ NO_PATH_COLOR = mcrfpy.Color(255, 0, 0) # Pure red for unreachable
|
|||
|
||||
# Global state
|
||||
grid = None
|
||||
grid_data = None
|
||||
color_layer = None
|
||||
entities = []
|
||||
current_combo_index = 0
|
||||
all_combinations = [] # All possible pairs
|
||||
current_path = []
|
||||
failures = []
|
||||
|
||||
dijkstra_all = mcrfpy.Scene("dijkstra_all")
|
||||
|
||||
def check(condition, message):
|
||||
"""Record a failed expectation instead of aborting the whole run."""
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
print(f"FAIL: {message}")
|
||||
return condition
|
||||
|
||||
def create_map():
|
||||
"""Create the map with entities"""
|
||||
global grid, color_layer, entities, all_combinations
|
||||
global grid, grid_data, color_layer, entities, all_combinations
|
||||
|
||||
dijkstra_all = mcrfpy.Scene("dijkstra_all")
|
||||
|
||||
# Create grid
|
||||
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
|
||||
# Create grid (grid_size= replaces the old grid_w/grid_h kwargs)
|
||||
grid = mcrfpy.Grid(grid_size=(14, 10))
|
||||
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||
grid_data = grid.grid_data
|
||||
|
||||
# Add color layer for cell coloring
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
# Add color layer for cell coloring (GridPoint has no .color anymore)
|
||||
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||
grid.add_layer(color_layer)
|
||||
|
||||
# Map layout - Entity 1 is intentionally trapped!
|
||||
map_layout = [
|
||||
|
|
@ -57,14 +72,14 @@ def create_map():
|
|||
entity_positions = []
|
||||
for y, row in enumerate(map_layout):
|
||||
for x, char in enumerate(row):
|
||||
cell = grid.at(x, y)
|
||||
cell = grid_data.at(x, y)
|
||||
|
||||
if char == 'W':
|
||||
cell.walkable = False
|
||||
color_layer.set(x, y, WALL_COLOR)
|
||||
color_layer.set((x, y), WALL_COLOR)
|
||||
else:
|
||||
cell.walkable = True
|
||||
color_layer.set(x, y, FLOOR_COLOR)
|
||||
color_layer.set((x, y), FLOOR_COLOR)
|
||||
|
||||
if char == 'E':
|
||||
entity_positions.append((x, y))
|
||||
|
|
@ -72,38 +87,41 @@ def create_map():
|
|||
# Create entities
|
||||
entities = []
|
||||
for i, (x, y) in enumerate(entity_positions):
|
||||
entity = mcrfpy.Entity((x, y), grid=grid)
|
||||
entity = mcrfpy.Entity(grid_pos=(x, y), grid=grid)
|
||||
entity.sprite_index = 49 + i # '1', '2', '3'
|
||||
entities.append(entity)
|
||||
|
||||
|
||||
print("Map Analysis:")
|
||||
print("=============")
|
||||
for i, (x, y) in enumerate(entity_positions):
|
||||
print(f"Entity {i+1} at ({x}, {y})")
|
||||
|
||||
|
||||
check(len(entities) == 3, f"expected 3 entities from the map, got {len(entities)}")
|
||||
check(entity_positions == [(10, 2), (6, 4), (0, 5)],
|
||||
f"unexpected entity positions: {entity_positions}")
|
||||
|
||||
# Generate ALL combinations (including invalid ones)
|
||||
all_combinations = []
|
||||
for i in range(len(entities)):
|
||||
for j in range(len(entities)):
|
||||
if i != j: # Skip self-paths
|
||||
all_combinations.append((i, j))
|
||||
|
||||
|
||||
print(f"\nTotal path combinations to test: {len(all_combinations)}")
|
||||
|
||||
def clear_path_colors():
|
||||
"""Reset all floor tiles to original color"""
|
||||
global current_path
|
||||
|
||||
for y in range(grid.grid_h):
|
||||
for x in range(grid.grid_w):
|
||||
cell = grid.at(x, y)
|
||||
if cell.walkable:
|
||||
color_layer.set(x, y, FLOOR_COLOR)
|
||||
for y in range(grid_data.grid_h):
|
||||
for x in range(grid_data.grid_w):
|
||||
if grid_data.at(x, y).walkable:
|
||||
color_layer.set((x, y), FLOOR_COLOR)
|
||||
|
||||
current_path = []
|
||||
|
||||
def show_combination(index):
|
||||
"""Show a specific path combination (valid or invalid)"""
|
||||
"""Show a specific path combination (valid or invalid); returns the path"""
|
||||
global current_combo_index, current_path
|
||||
|
||||
current_combo_index = index % len(all_combinations)
|
||||
|
|
@ -116,44 +134,63 @@ def show_combination(index):
|
|||
e_from = entities[from_idx]
|
||||
e_to = entities[to_idx]
|
||||
|
||||
# Calculate path
|
||||
path = e_from.path_to(int(e_to.x), int(e_to.y))
|
||||
# Calculate path (grid_x/grid_y are tile coords; .x/.y are pixels now)
|
||||
path = e_from.path_to(e_to.grid_x, e_to.grid_y)
|
||||
current_path = path if path else []
|
||||
|
||||
# Always color start and end positions
|
||||
color_layer.set(int(e_from.x), int(e_from.y), START_COLOR)
|
||||
color_layer.set(int(e_to.x), int(e_to.y), NO_PATH_COLOR if not path else END_COLOR)
|
||||
color_layer.set((e_from.grid_x, e_from.grid_y), START_COLOR)
|
||||
color_layer.set((e_to.grid_x, e_to.grid_y),
|
||||
NO_PATH_COLOR if not path else END_COLOR)
|
||||
|
||||
# Color the path if it exists
|
||||
if path:
|
||||
# Color intermediate steps
|
||||
for i, (x, y) in enumerate(path):
|
||||
if i > 0 and i < len(path) - 1:
|
||||
color_layer.set(x, y, PATH_COLOR)
|
||||
color_layer.set((x, y), PATH_COLOR)
|
||||
|
||||
status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} → Entity {to_idx+1} = {len(path)} steps"
|
||||
status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} -> Entity {to_idx+1} = {len(path)} steps"
|
||||
status_text.fill_color = mcrfpy.Color(100, 255, 100) # Green for valid
|
||||
|
||||
# Show path steps
|
||||
path_display = []
|
||||
for i, (x, y) in enumerate(path[:5]):
|
||||
for (x, y) in path[:5]:
|
||||
path_display.append(f"({x},{y})")
|
||||
if len(path) > 5:
|
||||
path_display.append("...")
|
||||
path_text.text = "Path: " + " → ".join(path_display)
|
||||
path_text.text = "Path: " + " -> ".join(path_display)
|
||||
else:
|
||||
status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} → Entity {to_idx+1} = NO PATH!"
|
||||
status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} -> Entity {to_idx+1} = NO PATH!"
|
||||
status_text.fill_color = mcrfpy.Color(255, 100, 100) # Red for invalid
|
||||
path_text.text = "Path: [] (No valid path exists)"
|
||||
|
||||
# Update info
|
||||
info_text.text = f"From: Entity {from_idx+1} at ({int(e_from.x)}, {int(e_from.y)}) | To: Entity {to_idx+1} at ({int(e_to.x)}, {int(e_to.y)})"
|
||||
info_text.text = f"From: Entity {from_idx+1} at ({e_from.grid_x}, {e_from.grid_y}) | To: Entity {to_idx+1} at ({e_to.grid_x}, {e_to.grid_y})"
|
||||
|
||||
return path
|
||||
|
||||
def validate_path(path, e_from, e_to, label):
|
||||
"""A valid path must be contiguous, walkable, and connect the endpoints"""
|
||||
check(path[-1] == (e_to.grid_x, e_to.grid_y),
|
||||
f"{label}: path does not end at the target: {path[-1]}")
|
||||
# path_to() excludes the source cell; the first step must be adjacent to it
|
||||
sx, sy = e_from.grid_x, e_from.grid_y
|
||||
check((sx, sy) not in path, f"{label}: path should not include the source cell")
|
||||
check(max(abs(path[0][0] - sx), abs(path[0][1] - sy)) == 1,
|
||||
f"{label}: first step {path[0]} is not adjacent to source ({sx}, {sy})")
|
||||
for (x, y) in path:
|
||||
check(grid_data.at(x, y).walkable,
|
||||
f"{label}: path crosses unwalkable cell ({x}, {y})")
|
||||
for (ax, ay), (bx, by) in zip(path, path[1:]):
|
||||
check(max(abs(ax - bx), abs(ay - by)) == 1,
|
||||
f"{label}: non-contiguous step ({ax},{ay}) -> ({bx},{by})")
|
||||
|
||||
def handle_keypress(key_str, state):
|
||||
"""Handle keyboard input"""
|
||||
"""Handle keyboard input (interactive mode only)"""
|
||||
global current_combo_index
|
||||
if state == mcrfpy.InputState.RELEASED: return
|
||||
|
||||
|
||||
if key_str == mcrfpy.Key.ESCAPE or key_str == mcrfpy.Key.Q:
|
||||
print("\nExiting...")
|
||||
sys.exit(0)
|
||||
|
|
@ -164,8 +201,8 @@ def handle_keypress(key_str, state):
|
|||
elif key_str == mcrfpy.Key.R:
|
||||
show_combination(current_combo_index)
|
||||
else:
|
||||
num_keys = {mcrfpy.Key.NUM_1: 0, mcrfpy.Key.NUM_2: 1, mcrfpy.Key.NUM_3: 2,
|
||||
mcrfpy.Key.NUM_4: 3, mcrfpy.Key.NUM_5: 4, mcrfpy.Key.NUM_6: 5}
|
||||
num_keys = {mcrfpy.Key.Num1: 0, mcrfpy.Key.Num2: 1, mcrfpy.Key.Num3: 2,
|
||||
mcrfpy.Key.Num4: 3, mcrfpy.Key.Num5: 4, mcrfpy.Key.Num6: 5}
|
||||
if key_str in num_keys:
|
||||
combo_num = num_keys[key_str]
|
||||
if combo_num < len(all_combinations):
|
||||
|
|
@ -214,12 +251,12 @@ controls.fill_color = mcrfpy.Color(150, 150, 150)
|
|||
ui.append(controls)
|
||||
|
||||
# Add legend
|
||||
legend = mcrfpy.Caption(pos=(120, 560), text="Red Start→Blue End (valid) | Red Start→Red End (invalid)")
|
||||
legend = mcrfpy.Caption(pos=(120, 560), text="Red Start->Blue End (valid) | Red Start->Red End (invalid)")
|
||||
legend.fill_color = mcrfpy.Color(150, 150, 150)
|
||||
ui.append(legend)
|
||||
|
||||
# Expected results info
|
||||
expected = mcrfpy.Caption(pos=(120, 580), text="Entity 1 is trapped: paths 1→2, 1→3, 2→1, 3→1 will fail")
|
||||
expected = mcrfpy.Caption(pos=(120, 580), text="Entity 1 is trapped: paths 1->2, 1->3, 2->1, 3->1 will fail")
|
||||
expected.fill_color = mcrfpy.Color(255, 150, 150)
|
||||
ui.append(expected)
|
||||
|
||||
|
|
@ -227,14 +264,36 @@ ui.append(expected)
|
|||
dijkstra_all.activate()
|
||||
dijkstra_all.on_key = handle_keypress
|
||||
|
||||
# Show first combination
|
||||
show_combination(0)
|
||||
print("\nExpected results:")
|
||||
print(" Path 1: Entity 1->2 = NO PATH (Entity 1 is trapped)")
|
||||
print(" Path 2: Entity 1->3 = NO PATH (Entity 1 is trapped)")
|
||||
print(" Path 3: Entity 2->1 = NO PATH (Entity 1 is trapped)")
|
||||
print(" Path 4: Entity 2->3 = Valid path")
|
||||
print(" Path 5: Entity 3->1 = NO PATH (Entity 1 is trapped)")
|
||||
print(" Path 6: Entity 3->2 = Valid path")
|
||||
print()
|
||||
|
||||
print("\nDemo ready!")
|
||||
print("Expected results:")
|
||||
print(" Path 1: Entity 1→2 = NO PATH (Entity 1 is trapped)")
|
||||
print(" Path 2: Entity 1→3 = NO PATH (Entity 1 is trapped)")
|
||||
print(" Path 3: Entity 2→1 = NO PATH (Entity 1 is trapped)")
|
||||
print(" Path 4: Entity 2→3 = Valid path")
|
||||
print(" Path 5: Entity 3→1 = NO PATH (Entity 1 is trapped)")
|
||||
print(" Path 6: Entity 3→2 = Valid path")
|
||||
# Drive every combination and verify the outcome. Entity index 0 is trapped,
|
||||
# so any pair involving it must yield an empty list; the other pairs must be
|
||||
# real paths.
|
||||
for combo_index, (from_idx, to_idx) in enumerate(all_combinations):
|
||||
path = show_combination(combo_index)
|
||||
label = f"Entity {from_idx+1}->{to_idx+1}"
|
||||
if 0 in (from_idx, to_idx):
|
||||
check(path == [],
|
||||
f"{label}: expected NO PATH (entity 1 is walled in), got {path}")
|
||||
print(f" {label}: [] (correctly unreachable)")
|
||||
else:
|
||||
if check(bool(path), f"{label}: expected a valid path, got {path}"):
|
||||
validate_path(path, entities[from_idx], entities[to_idx], label)
|
||||
print(f" {label}: {len(path)} steps, ends at {path[-1]}")
|
||||
|
||||
# Render once so the coloring path is exercised too (rendering costs no sim time)
|
||||
mcrfpy.automation.screenshot("dijkstra_all_paths.png")
|
||||
|
||||
if failures:
|
||||
print(f"\n{len(failures)} check(s) failed")
|
||||
sys.exit(1)
|
||||
|
||||
print("\nPASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ Dijkstra Demo - Cycles Through Different Path Combinations
|
|||
==========================================================
|
||||
|
||||
Shows paths between different entity pairs, skipping impossible paths.
|
||||
|
||||
Headless verification of the original demo's claims:
|
||||
- Entity 1 is walled into a pocket and can reach nobody.
|
||||
- Entities 2 and 3 can reach each other (both directions), so exactly two
|
||||
path combinations exist.
|
||||
- Each generated path is a contiguous chain of walkable cells ending on the
|
||||
target entity.
|
||||
- Cycling through the combinations recolors the ColorLayer accordingly.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
|
|
@ -18,24 +26,33 @@ END_COLOR = mcrfpy.Color(100, 100, 255) # Light blue
|
|||
|
||||
# Global state
|
||||
grid = None
|
||||
grid_data = None
|
||||
color_layer = None
|
||||
entities = []
|
||||
current_path_index = 0
|
||||
path_combinations = []
|
||||
current_path = []
|
||||
failures = []
|
||||
|
||||
def check(condition, message):
|
||||
"""Record a failed assertion without aborting the demo"""
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
print(f" FAIL: {message}")
|
||||
return condition
|
||||
|
||||
def create_map():
|
||||
"""Create the map with entities"""
|
||||
global grid, color_layer, entities
|
||||
global grid, grid_data, color_layer, entities
|
||||
|
||||
dijkstra_cycle = mcrfpy.Scene("dijkstra_cycle")
|
||||
|
||||
# Create grid
|
||||
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
|
||||
# Create grid (grid_size= replaces the old grid_w=/grid_h= kwargs)
|
||||
grid = mcrfpy.Grid(grid_size=(14, 10))
|
||||
grid_data = grid.grid_data
|
||||
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||
|
||||
# Add color layer for cell coloring
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
# Add color layer for cell coloring. Cells no longer have a .color;
|
||||
# ColorLayer objects are constructed then attached (add_layer takes no kwargs).
|
||||
color_layer = grid_data.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
|
||||
|
||||
# Map layout
|
||||
map_layout = [
|
||||
|
|
@ -55,14 +72,14 @@ def create_map():
|
|||
entity_positions = []
|
||||
for y, row in enumerate(map_layout):
|
||||
for x, char in enumerate(row):
|
||||
cell = grid.at(x, y)
|
||||
cell = grid_data.at(x, y)
|
||||
|
||||
if char == 'W':
|
||||
cell.walkable = False
|
||||
color_layer.set(x, y, WALL_COLOR)
|
||||
color_layer.set((x, y), WALL_COLOR)
|
||||
else:
|
||||
cell.walkable = True
|
||||
color_layer.set(x, y, FLOOR_COLOR)
|
||||
color_layer.set((x, y), FLOOR_COLOR)
|
||||
|
||||
if char == 'E':
|
||||
entity_positions.append((x, y))
|
||||
|
|
@ -70,58 +87,89 @@ def create_map():
|
|||
# Create entities
|
||||
entities = []
|
||||
for i, (x, y) in enumerate(entity_positions):
|
||||
entity = mcrfpy.Entity((x, y), grid=grid)
|
||||
entity = mcrfpy.Entity(grid_pos=(x, y), grid=grid)
|
||||
entity.sprite_index = 49 + i # '1', '2', '3'
|
||||
entities.append(entity)
|
||||
|
||||
|
||||
print("Entities created:")
|
||||
for i, (x, y) in enumerate(entity_positions):
|
||||
print(f" Entity {i+1} at ({x}, {y})")
|
||||
|
||||
|
||||
check(entity_positions == [(10, 2), (6, 4), (0, 5)],
|
||||
f"expected entities at [(10,2),(6,4),(0,5)], got {entity_positions}")
|
||||
|
||||
# Check which entity is trapped
|
||||
print("\nChecking accessibility:")
|
||||
reachability = []
|
||||
for i, e in enumerate(entities):
|
||||
# Try to path to each other entity
|
||||
can_reach = []
|
||||
for j, other in enumerate(entities):
|
||||
if i != j:
|
||||
path = e.path_to(int(other.x), int(other.y))
|
||||
path = e.path_to(other.grid_x, other.grid_y)
|
||||
if path:
|
||||
can_reach.append(j+1)
|
||||
|
||||
|
||||
reachability.append(can_reach)
|
||||
if not can_reach:
|
||||
print(f" Entity {i+1} at ({int(e.x)}, {int(e.y)}) is TRAPPED!")
|
||||
print(f" Entity {i+1} at ({e.grid_x}, {e.grid_y}) is TRAPPED!")
|
||||
else:
|
||||
print(f" Entity {i+1} can reach entities: {can_reach}")
|
||||
|
||||
|
||||
# Entity 1 sits in a sealed pocket; entities 2 and 3 share the open area.
|
||||
check(reachability[0] == [], "Entity 1 should be trapped (no reachable entities)")
|
||||
check(reachability[1] == [3], f"Entity 2 should reach only Entity 3, got {reachability[1]}")
|
||||
check(reachability[2] == [2], f"Entity 3 should reach only Entity 2, got {reachability[2]}")
|
||||
|
||||
# Generate valid path combinations (excluding trapped entity)
|
||||
global path_combinations
|
||||
path_combinations = []
|
||||
|
||||
|
||||
# Only paths between entities 2 and 3 (indices 1 and 2) will work
|
||||
# since entity 1 (index 0) is trapped
|
||||
if len(entities) >= 3:
|
||||
# Entity 2 to Entity 3
|
||||
path = entities[1].path_to(int(entities[2].x), int(entities[2].y))
|
||||
path = entities[1].path_to(entities[2].grid_x, entities[2].grid_y)
|
||||
if path:
|
||||
path_combinations.append((1, 2, path))
|
||||
|
||||
|
||||
# Entity 3 to Entity 2
|
||||
path = entities[2].path_to(int(entities[1].x), int(entities[1].y))
|
||||
path = entities[2].path_to(entities[1].grid_x, entities[1].grid_y)
|
||||
if path:
|
||||
path_combinations.append((2, 1, path))
|
||||
|
||||
|
||||
print(f"\nFound {len(path_combinations)} valid paths")
|
||||
check(len(path_combinations) == 2, f"expected 2 valid path combinations, got {len(path_combinations)}")
|
||||
|
||||
def validate_path(from_idx, to_idx, path):
|
||||
"""A path must be a contiguous chain of walkable cells ending on the target"""
|
||||
e_from = entities[from_idx]
|
||||
e_to = entities[to_idx]
|
||||
|
||||
check(len(path) > 0, f"path {from_idx+1}->{to_idx+1} is empty")
|
||||
if not path:
|
||||
return
|
||||
|
||||
check(tuple(path[-1]) == (e_to.grid_x, e_to.grid_y),
|
||||
f"path {from_idx+1}->{to_idx+1} does not end on target: {path[-1]}")
|
||||
|
||||
# path_to() excludes the entity's own cell, so walk from the entity position
|
||||
prev = (e_from.grid_x, e_from.grid_y)
|
||||
for (x, y) in path:
|
||||
check(grid_data.at(x, y).walkable, f"path crosses non-walkable cell ({x}, {y})")
|
||||
step = max(abs(x - prev[0]), abs(y - prev[1]))
|
||||
check(step == 1, f"path is not contiguous: {prev} -> ({x}, {y})")
|
||||
prev = (x, y)
|
||||
|
||||
def clear_path_colors():
|
||||
"""Reset all floor tiles to original color"""
|
||||
global current_path
|
||||
|
||||
for y in range(grid.grid_h):
|
||||
for x in range(grid.grid_w):
|
||||
cell = grid.at(x, y)
|
||||
for y in range(grid_data.grid_h):
|
||||
for x in range(grid_data.grid_w):
|
||||
cell = grid_data.at(x, y)
|
||||
if cell.walkable:
|
||||
color_layer.set(x, y, FLOOR_COLOR)
|
||||
color_layer.set((x, y), FLOOR_COLOR)
|
||||
|
||||
current_path = []
|
||||
|
||||
|
|
@ -147,16 +195,16 @@ def show_path(index):
|
|||
current_path = path
|
||||
if path:
|
||||
# Color start and end
|
||||
color_layer.set(int(e_from.x), int(e_from.y), START_COLOR)
|
||||
color_layer.set(int(e_to.x), int(e_to.y), END_COLOR)
|
||||
color_layer.set((e_from.grid_x, e_from.grid_y), START_COLOR)
|
||||
color_layer.set((e_to.grid_x, e_to.grid_y), END_COLOR)
|
||||
|
||||
# Color intermediate steps
|
||||
for i, (x, y) in enumerate(path):
|
||||
if i > 0 and i < len(path) - 1:
|
||||
color_layer.set(x, y, PATH_COLOR)
|
||||
# Color intermediate steps. path_to() excludes the entity's own cell,
|
||||
# so every element except the last (the target) is an intermediate step.
|
||||
for (x, y) in path[:-1]:
|
||||
color_layer.set((x, y), PATH_COLOR)
|
||||
|
||||
# Update status
|
||||
status_text.text = f"Path {current_path_index + 1}/{len(path_combinations)}: Entity {from_idx+1} → Entity {to_idx+1} ({len(path)} steps)"
|
||||
status_text.text = f"Path {current_path_index + 1}/{len(path_combinations)}: Entity {from_idx+1} -> Entity {to_idx+1} ({len(path)} steps)"
|
||||
|
||||
# Update path display
|
||||
path_display = []
|
||||
|
|
@ -164,7 +212,7 @@ def show_path(index):
|
|||
path_display.append(f"({x},{y})")
|
||||
if len(path) > 5:
|
||||
path_display.append("...")
|
||||
path_text.text = "Path: " + " → ".join(path_display) if path_display else "Path: None"
|
||||
path_text.text = "Path: " + " -> ".join(path_display) if path_display else "Path: None"
|
||||
|
||||
def handle_keypress(key_str, state):
|
||||
"""Handle keyboard input"""
|
||||
|
|
@ -186,6 +234,8 @@ print("==========================")
|
|||
print("Note: Entity 1 is trapped by walls!")
|
||||
print()
|
||||
|
||||
dijkstra_cycle = mcrfpy.Scene("dijkstra_cycle")
|
||||
|
||||
create_map()
|
||||
|
||||
# Set up UI
|
||||
|
|
@ -231,9 +281,34 @@ if path_combinations:
|
|||
else:
|
||||
status_text.text = "No valid paths! Entity 1 is trapped!"
|
||||
|
||||
print("\nDemo ready!")
|
||||
print("Controls:")
|
||||
print(" SPACE or N - Next path")
|
||||
print(" P - Previous path")
|
||||
print(" R - Refresh current path")
|
||||
print(" Q - Quit")
|
||||
# --- Headless verification: cycle every combination the demo would show ---
|
||||
print("\nCycling paths:")
|
||||
for i in range(len(path_combinations)):
|
||||
show_path(i)
|
||||
from_idx, to_idx, path = path_combinations[current_path_index]
|
||||
print(f" {status_text.text}")
|
||||
check(current_path_index == i, f"show_path({i}) selected index {current_path_index}")
|
||||
validate_path(from_idx, to_idx, path)
|
||||
|
||||
# The colored overlay must reflect the displayed path
|
||||
e_from, e_to = entities[from_idx], entities[to_idx]
|
||||
check(color_layer.at((e_from.grid_x, e_from.grid_y)) == START_COLOR,
|
||||
"start cell not colored START_COLOR")
|
||||
check(color_layer.at((e_to.grid_x, e_to.grid_y)) == END_COLOR,
|
||||
"end cell not colored END_COLOR")
|
||||
for (x, y) in path[:-1]:
|
||||
check(color_layer.at((x, y)) == PATH_COLOR, f"path cell ({x},{y}) not colored PATH_COLOR")
|
||||
|
||||
# Cycling wraps around (what SPACE/N does)
|
||||
show_path(len(path_combinations))
|
||||
check(current_path_index == 0, "cycling past the last path should wrap to 0")
|
||||
|
||||
# Render once to prove the scene is drawable with the layers attached
|
||||
mcrfpy.automation.screenshot("dijkstra_cycle_paths.png")
|
||||
|
||||
if failures:
|
||||
print(f"\nFAIL: {len(failures)} check(s) failed")
|
||||
sys.exit(1)
|
||||
|
||||
print("\nPASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -18,40 +18,49 @@ ENTITY_COLORS = [
|
|||
|
||||
# Global state
|
||||
grid = None
|
||||
grid_data = None
|
||||
color_layer = None
|
||||
entities = []
|
||||
first_point = None
|
||||
second_point = None
|
||||
failures = []
|
||||
|
||||
WALLS = [(5, 2), (5, 3), (5, 4), (5, 5), (5, 6)]
|
||||
|
||||
def check(condition, message):
|
||||
"""Record a failed check instead of dying at the first one."""
|
||||
if not condition:
|
||||
print(f" FAIL: {message}")
|
||||
failures.append(message)
|
||||
return condition
|
||||
|
||||
def create_simple_map():
|
||||
"""Create a simple test map"""
|
||||
global grid, color_layer, entities
|
||||
|
||||
dijkstra_debug = mcrfpy.Scene("dijkstra_debug")
|
||||
global grid, grid_data, color_layer, entities
|
||||
|
||||
# Small grid for easy debugging
|
||||
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||
# Pathfinding + layers live on the shared GridData (#313/#361)
|
||||
grid_data = grid.grid_data
|
||||
|
||||
# Add color layer for cell coloring
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||
grid_data.add_layer(color_layer)
|
||||
|
||||
print("Initializing 10x10 grid...")
|
||||
|
||||
# Initialize all as floor
|
||||
for y in range(10):
|
||||
for x in range(10):
|
||||
grid.at(x, y).walkable = True
|
||||
grid.at(x, y).transparent = True
|
||||
color_layer.set(x, y, FLOOR_COLOR)
|
||||
grid_data.at(x, y).walkable = True
|
||||
grid_data.at(x, y).transparent = True
|
||||
color_layer.set((x, y), FLOOR_COLOR)
|
||||
|
||||
# Add a simple wall
|
||||
print("Adding walls at:")
|
||||
walls = [(5, 2), (5, 3), (5, 4), (5, 5), (5, 6)]
|
||||
for x, y in walls:
|
||||
for x, y in WALLS:
|
||||
print(f" Wall at ({x}, {y})")
|
||||
grid.at(x, y).walkable = False
|
||||
color_layer.set(x, y, WALL_COLOR)
|
||||
grid_data.at(x, y).walkable = False
|
||||
color_layer.set((x, y), WALL_COLOR)
|
||||
|
||||
# Create 3 entities
|
||||
entity_positions = [(2, 5), (8, 5), (5, 8)]
|
||||
|
|
@ -60,7 +69,7 @@ def create_simple_map():
|
|||
print("\nCreating entities at:")
|
||||
for i, (x, y) in enumerate(entity_positions):
|
||||
print(f" Entity {i+1} at ({x}, {y})")
|
||||
entity = mcrfpy.Entity((x, y), grid=grid)
|
||||
entity = mcrfpy.Entity(grid_pos=(x, y), grid=grid)
|
||||
entity.sprite_index = 49 + i # '1', '2', '3'
|
||||
entities.append(entity)
|
||||
|
||||
|
|
@ -70,70 +79,95 @@ def test_path_highlighting():
|
|||
"""Test path highlighting with debug output"""
|
||||
print("\n" + "="*50)
|
||||
print("Testing path highlighting...")
|
||||
|
||||
|
||||
# Select first two entities
|
||||
e1 = entities[0]
|
||||
e2 = entities[1]
|
||||
|
||||
print(f"\nEntity 1 position: ({e1.x}, {e1.y})")
|
||||
print(f"Entity 2 position: ({e2.x}, {e2.y})")
|
||||
|
||||
|
||||
# NOTE: entity.x/.y are PIXEL coordinates now; tile coords are grid_x/grid_y.
|
||||
print(f"\nEntity 1 position: ({e1.grid_x}, {e1.grid_y})")
|
||||
print(f"Entity 2 position: ({e2.grid_x}, {e2.grid_y})")
|
||||
|
||||
# Use entity.path_to()
|
||||
print("\nCalling entity.path_to()...")
|
||||
path = e1.path_to(int(e2.x), int(e2.y))
|
||||
|
||||
path = e1.path_to(e2.grid_x, e2.grid_y)
|
||||
|
||||
print(f"Path returned: {path}")
|
||||
print(f"Path length: {len(path)} steps")
|
||||
|
||||
|
||||
check(len(path) > 0, "entity.path_to() returned an empty path")
|
||||
if path:
|
||||
check(tuple(path[-1]) == (e2.grid_x, e2.grid_y),
|
||||
f"path does not end at the target: {path[-1]}")
|
||||
|
||||
print("\nHighlighting path cells:")
|
||||
for i, (x, y) in enumerate(path):
|
||||
print(f" Step {i}: ({x}, {y})")
|
||||
# Get current color for debugging
|
||||
cell = grid.at(x, y)
|
||||
old_c = color_layer.at(x, y)
|
||||
cell = grid_data.at(x, y)
|
||||
old_c = color_layer.at((x, y))
|
||||
old_color = (old_c.r, old_c.g, old_c.b)
|
||||
|
||||
# Set new color
|
||||
color_layer.set(x, y, PATH_COLOR)
|
||||
new_c = color_layer.at(x, y)
|
||||
color_layer.set((x, y), PATH_COLOR)
|
||||
new_c = color_layer.at((x, y))
|
||||
new_color = (new_c.r, new_c.g, new_c.b)
|
||||
|
||||
print(f" Color changed from {old_color} to {new_color}")
|
||||
print(f" Walkable: {cell.walkable}")
|
||||
|
||||
check(cell.walkable, f"path routed through non-walkable cell ({x}, {y})")
|
||||
check((x, y) not in WALLS, f"path routed through a wall at ({x}, {y})")
|
||||
|
||||
# Also test grid's Dijkstra methods
|
||||
print("\n" + "-"*30)
|
||||
print("Testing grid Dijkstra methods...")
|
||||
|
||||
grid.compute_dijkstra(int(e1.x), int(e1.y))
|
||||
grid_path = grid.get_dijkstra_path(int(e2.x), int(e2.y))
|
||||
distance = grid.get_dijkstra_distance(int(e2.x), int(e2.y))
|
||||
|
||||
|
||||
# compute_dijkstra/get_dijkstra_path/get_dijkstra_distance were replaced by
|
||||
# GridData.get_dijkstra_map() -> DijkstraMap.path_from()/.distance()
|
||||
dmap = grid_data.get_dijkstra_map(root=(e1.grid_x, e1.grid_y))
|
||||
grid_path = list(dmap.path_from((e2.grid_x, e2.grid_y)))
|
||||
distance = dmap.distance((e2.grid_x, e2.grid_y))
|
||||
|
||||
print(f"Grid path: {grid_path}")
|
||||
print(f"Grid distance: {distance}")
|
||||
|
||||
|
||||
check(distance is not None, "Dijkstra distance to entity 2 is unreachable")
|
||||
check(len(grid_path) > 0, "Dijkstra path_from() returned an empty path")
|
||||
for x, y in grid_path:
|
||||
check(grid_data.at(x, y).walkable,
|
||||
f"Dijkstra path routed through non-walkable cell ({x}, {y})")
|
||||
# Dijkstra distance must be at least the straight-line-blocked A* step count
|
||||
check(distance is None or distance >= len(path) - 0.001,
|
||||
f"Dijkstra distance {distance} shorter than A* path length {len(path)}")
|
||||
|
||||
# Verify colors were set
|
||||
print("\nVerifying cell colors after highlighting:")
|
||||
for x, y in path[:3]: # Check first 3 cells
|
||||
c = color_layer.at(x, y)
|
||||
c = color_layer.at((x, y))
|
||||
color = (c.r, c.g, c.b)
|
||||
expected = (PATH_COLOR.r, PATH_COLOR.g, PATH_COLOR.b)
|
||||
match = color == expected
|
||||
print(f" Cell ({x}, {y}): color={color}, expected={expected}, match={match}")
|
||||
check(match, f"ColorLayer did not retain PATH_COLOR at ({x}, {y})")
|
||||
|
||||
def handle_keypress(scene_name, keycode):
|
||||
# Cells that were never on the path must keep their original color
|
||||
wall_c = color_layer.at(WALLS[0])
|
||||
check((wall_c.r, wall_c.g, wall_c.b) == (WALL_COLOR.r, WALL_COLOR.g, WALL_COLOR.b),
|
||||
"wall cell color was clobbered by path highlighting")
|
||||
|
||||
def handle_keypress(key, action):
|
||||
"""Simple keypress handler"""
|
||||
if keycode == 81 or keycode == 113 or keycode == 256: # Q/q/ESC
|
||||
if key == mcrfpy.Key.Q or key == mcrfpy.Key.Escape:
|
||||
print("\nExiting debug...")
|
||||
sys.exit(0)
|
||||
elif keycode == 32: # Space
|
||||
elif key == mcrfpy.Key.Space:
|
||||
print("\nSpace pressed - retesting path highlighting...")
|
||||
test_path_highlighting()
|
||||
|
||||
# Create the map
|
||||
print("Dijkstra Debug Test")
|
||||
print("===================")
|
||||
dijkstra_debug = mcrfpy.Scene("dijkstra_debug")
|
||||
grid = create_simple_map()
|
||||
|
||||
# Initial path test
|
||||
|
|
@ -161,6 +195,17 @@ ui.append(info)
|
|||
dijkstra_debug.on_key = handle_keypress
|
||||
dijkstra_debug.activate()
|
||||
|
||||
print("\nScene ready. The path should be highlighted in cyan.")
|
||||
print("If you don't see the path, there may be a rendering issue.")
|
||||
print("Press SPACE to retest, Q to quit.")
|
||||
# Render once to prove the highlighted grid actually draws (the original test's
|
||||
# "if you don't see the path there may be a rendering issue" concern).
|
||||
mcrfpy.step(0.016)
|
||||
mcrfpy.automation.screenshot("dijkstra_debug.png")
|
||||
|
||||
print("\n" + "="*50)
|
||||
if failures:
|
||||
print(f"FAIL: {len(failures)} check(s) failed:")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,20 +1,26 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Dijkstra Pathfinding Interactive Demo
|
||||
=====================================
|
||||
Dijkstra Pathfinding Interactive Demo (headless self-test)
|
||||
==========================================================
|
||||
|
||||
Interactive visualization showing Dijkstra pathfinding between entities.
|
||||
Visualization + verification of Dijkstra pathfinding between entities.
|
||||
|
||||
Controls:
|
||||
Controls (when run with a window):
|
||||
- Press 1/2/3 to select the first entity
|
||||
- Press A/B/C to select the second entity
|
||||
- Press A/B/C to select the second entity
|
||||
- Space to clear selection
|
||||
- Q or ESC to quit
|
||||
|
||||
The path between selected entities is automatically highlighted.
|
||||
The path between selected entities is highlighted on a ColorLayer.
|
||||
|
||||
Under --headless the same selection logic is driven programmatically through the
|
||||
key handler and the resulting paths / distances / highlight colors are asserted.
|
||||
Entity 1 lives in a sealed room, so it must be unreachable from the others;
|
||||
entities 2 and 3 are separated by a long wall and must be reachable around it.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
import sys
|
||||
|
||||
# Colors - using more distinct values
|
||||
|
|
@ -29,55 +35,65 @@ ENTITY_COLORS = [
|
|||
|
||||
# Global state
|
||||
grid = None
|
||||
grid_data = None
|
||||
color_layer = None
|
||||
entities = []
|
||||
first_point = None
|
||||
second_point = None
|
||||
last_path = None
|
||||
failures = []
|
||||
|
||||
def check(condition, message):
|
||||
"""Record a failed assertion without aborting the run"""
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
print(f"FAIL: {message}")
|
||||
return condition
|
||||
|
||||
# Define the map layout from user's specification
|
||||
# . = floor, W = wall, E = entity position
|
||||
map_layout = [
|
||||
"..............", # Row 0
|
||||
"..W.....WWWW..", # Row 1
|
||||
"..W.W...W.EW..", # Row 2
|
||||
"..W.....W..W..", # Row 3
|
||||
"..W...E.WWWW..", # Row 4
|
||||
"E.W...........", # Row 5
|
||||
"..W...........", # Row 6
|
||||
"..W...........", # Row 7
|
||||
"..W.WWW.......", # Row 8
|
||||
"..............", # Row 9
|
||||
]
|
||||
|
||||
def create_map():
|
||||
"""Create the interactive map with the layout specified by the user"""
|
||||
global grid, color_layer, entities
|
||||
|
||||
dijkstra_interactive = mcrfpy.Scene("dijkstra_interactive")
|
||||
global grid, grid_data, color_layer, entities
|
||||
|
||||
# Create grid - 14x10 as specified
|
||||
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
|
||||
grid = mcrfpy.Grid(grid_size=(14, 10))
|
||||
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||
grid_data = grid.grid_data
|
||||
|
||||
# Add color layer for cell coloring
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
|
||||
# Define the map layout from user's specification
|
||||
# . = floor, W = wall, E = entity position
|
||||
map_layout = [
|
||||
"..............", # Row 0
|
||||
"..W.....WWWW..", # Row 1
|
||||
"..W.W...W.EW..", # Row 2
|
||||
"..W.....W..W..", # Row 3
|
||||
"..W...E.WWWW..", # Row 4
|
||||
"E.W...........", # Row 5
|
||||
"..W...........", # Row 6
|
||||
"..W...........", # Row 7
|
||||
"..W.WWW.......", # Row 8
|
||||
"..............", # Row 9
|
||||
]
|
||||
# Add color layer for cell coloring (GridPoint has no .color anymore)
|
||||
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||
grid_data.add_layer(color_layer)
|
||||
|
||||
# Create the map
|
||||
entity_positions = []
|
||||
for y, row in enumerate(map_layout):
|
||||
for x, char in enumerate(row):
|
||||
cell = grid.at(x, y)
|
||||
cell = grid_data.at(x, y)
|
||||
|
||||
if char == 'W':
|
||||
# Wall
|
||||
cell.walkable = False
|
||||
cell.transparent = False
|
||||
color_layer.set(x, y, WALL_COLOR)
|
||||
color_layer.set((x, y), WALL_COLOR)
|
||||
else:
|
||||
# Floor
|
||||
cell.walkable = True
|
||||
cell.transparent = True
|
||||
color_layer.set(x, y, FLOOR_COLOR)
|
||||
color_layer.set((x, y), FLOOR_COLOR)
|
||||
|
||||
if char == 'E':
|
||||
# Entity position
|
||||
|
|
@ -86,25 +102,32 @@ def create_map():
|
|||
# Create entities at marked positions
|
||||
entities = []
|
||||
for i, (x, y) in enumerate(entity_positions):
|
||||
entity = mcrfpy.Entity((x, y), grid=grid)
|
||||
entity = mcrfpy.Entity(grid_pos=(x, y))
|
||||
entity.sprite_index = 49 + i # '1', '2', '3'
|
||||
grid_data.entities.append(entity)
|
||||
entities.append(entity)
|
||||
|
||||
return grid
|
||||
|
||||
def entity_cell(entity):
|
||||
"""Logical cell of an entity as an (x, y) int tuple"""
|
||||
cp = entity.cell_pos
|
||||
return (int(cp.x), int(cp.y))
|
||||
|
||||
def clear_path_highlight():
|
||||
"""Clear any existing path highlighting"""
|
||||
# Reset all floor tiles to original color
|
||||
for y in range(grid.grid_h):
|
||||
for x in range(grid.grid_w):
|
||||
cell = grid.at(x, y)
|
||||
if cell.walkable:
|
||||
color_layer.set(x, y, FLOOR_COLOR)
|
||||
for y in range(grid_data.grid_h):
|
||||
for x in range(grid_data.grid_w):
|
||||
if grid_data.at(x, y).walkable:
|
||||
color_layer.set((x, y), FLOOR_COLOR)
|
||||
|
||||
def highlight_path():
|
||||
"""Highlight the path between selected entities"""
|
||||
"""Highlight the path between selected entities. Returns the path (may be empty)."""
|
||||
global last_path
|
||||
last_path = None
|
||||
if first_point is None or second_point is None:
|
||||
return
|
||||
return None
|
||||
|
||||
# Clear previous highlighting
|
||||
clear_path_highlight()
|
||||
|
|
@ -112,72 +135,67 @@ def highlight_path():
|
|||
# Get entities
|
||||
entity1 = entities[first_point]
|
||||
entity2 = entities[second_point]
|
||||
start = entity_cell(entity1)
|
||||
end = entity_cell(entity2)
|
||||
|
||||
# Compute Dijkstra from first entity
|
||||
grid.compute_dijkstra(int(entity1.x), int(entity1.y))
|
||||
# Compute Dijkstra from first entity; walkability is static, but clear the
|
||||
# cache so each selection recomputes against the current grid.
|
||||
grid_data.clear_dijkstra_maps()
|
||||
dijkstra = grid_data.get_dijkstra_map(root=start)
|
||||
|
||||
# Get path to second entity
|
||||
path = grid.get_dijkstra_path(int(entity2.x), int(entity2.y))
|
||||
distance = dijkstra.distance(end)
|
||||
path = [] if distance is None else [(int(v.x), int(v.y)) for v in dijkstra.path_from(end)]
|
||||
|
||||
if path:
|
||||
# Highlight the path
|
||||
for x, y in path:
|
||||
cell = grid.at(x, y)
|
||||
if cell.walkable:
|
||||
color_layer.set(x, y, PATH_COLOR)
|
||||
if grid_data.at(x, y).walkable:
|
||||
color_layer.set((x, y), PATH_COLOR)
|
||||
|
||||
# Also highlight start and end with entity colors
|
||||
color_layer.set(int(entity1.x), int(entity1.y), ENTITY_COLORS[first_point])
|
||||
color_layer.set(int(entity2.x), int(entity2.y), ENTITY_COLORS[second_point])
|
||||
color_layer.set(start, ENTITY_COLORS[first_point])
|
||||
color_layer.set(end, ENTITY_COLORS[second_point])
|
||||
|
||||
# Update info
|
||||
distance = grid.get_dijkstra_distance(int(entity2.x), int(entity2.y))
|
||||
info_text.text = f"Path: Entity {first_point+1} to Entity {second_point+1} - {len(path)} steps, {distance:.1f} units"
|
||||
info_text.text = (f"Path: Entity {first_point+1} to Entity {second_point+1} - "
|
||||
f"{len(path)} steps, {distance:.1f} units")
|
||||
else:
|
||||
info_text.text = f"No path between Entity {first_point+1} and Entity {second_point+1}"
|
||||
|
||||
def handle_keypress(scene_name, keycode):
|
||||
print(f" {info_text.text}")
|
||||
last_path = path
|
||||
return path
|
||||
|
||||
def handle_keypress(key, action):
|
||||
"""Handle keyboard input"""
|
||||
global first_point, second_point
|
||||
|
||||
|
||||
if action != mcrfpy.InputState.PRESSED:
|
||||
return
|
||||
|
||||
# Number keys for first entity
|
||||
if keycode == 49: # '1'
|
||||
first_point = 0
|
||||
status_text.text = f"First: Entity 1 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
|
||||
if key in (mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3):
|
||||
first_point = {mcrfpy.Key.NUM_1: 0, mcrfpy.Key.NUM_2: 1, mcrfpy.Key.NUM_3: 2}[key]
|
||||
status_text.text = (f"First: Entity {first_point+1} | "
|
||||
f"Second: {f'Entity {second_point+1}' if second_point is not None else '?'}")
|
||||
highlight_path()
|
||||
elif keycode == 50: # '2'
|
||||
first_point = 1
|
||||
status_text.text = f"First: Entity 2 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
|
||||
highlight_path()
|
||||
elif keycode == 51: # '3'
|
||||
first_point = 2
|
||||
status_text.text = f"First: Entity 3 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
|
||||
highlight_path()
|
||||
|
||||
|
||||
# Letter keys for second entity
|
||||
elif keycode == 65 or keycode == 97: # 'A' or 'a'
|
||||
second_point = 0
|
||||
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 1"
|
||||
elif key in (mcrfpy.Key.A, mcrfpy.Key.B, mcrfpy.Key.C):
|
||||
second_point = {mcrfpy.Key.A: 0, mcrfpy.Key.B: 1, mcrfpy.Key.C: 2}[key]
|
||||
status_text.text = (f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | "
|
||||
f"Second: Entity {second_point+1}")
|
||||
highlight_path()
|
||||
elif keycode == 66 or keycode == 98: # 'B' or 'b'
|
||||
second_point = 1
|
||||
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 2"
|
||||
highlight_path()
|
||||
elif keycode == 67 or keycode == 99: # 'C' or 'c'
|
||||
second_point = 2
|
||||
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 3"
|
||||
highlight_path()
|
||||
|
||||
|
||||
# Clear selection
|
||||
elif keycode == 32: # Space
|
||||
elif key == mcrfpy.Key.SPACE:
|
||||
first_point = None
|
||||
second_point = None
|
||||
clear_path_highlight()
|
||||
status_text.text = "Press 1/2/3 for first entity, A/B/C for second"
|
||||
info_text.text = "Space to clear, Q to quit"
|
||||
|
||||
|
||||
# Quit
|
||||
elif keycode == 81 or keycode == 113 or keycode == 256: # Q/q/ESC
|
||||
elif key in (mcrfpy.Key.Q, mcrfpy.Key.ESCAPE):
|
||||
print("\nExiting Dijkstra interactive demo...")
|
||||
sys.exit(0)
|
||||
|
||||
|
|
@ -190,6 +208,8 @@ print(" A/B/C - Select second entity")
|
|||
print(" Space - Clear selection")
|
||||
print(" Q/ESC - Quit")
|
||||
|
||||
dijkstra_interactive = mcrfpy.Scene("dijkstra_interactive")
|
||||
|
||||
# Create map
|
||||
grid = create_map()
|
||||
|
||||
|
|
@ -221,14 +241,14 @@ legend1 = mcrfpy.Caption(pos=(120, 540), text="Entities: 1=Red 2=Green 3=Blue")
|
|||
legend1.fill_color = mcrfpy.Color(150, 150, 150)
|
||||
ui.append(legend1)
|
||||
|
||||
legend2 = mcrfpy.Caption(pos=(120, 560), text="Colors: Dark=Wall Light=Floor Cyan=Path")
|
||||
legend2 = mcrfpy.Caption(pos=(120, 560), text="Colors: Dark=Wall Light=Floor Green=Path")
|
||||
legend2.fill_color = mcrfpy.Color(150, 150, 150)
|
||||
ui.append(legend2)
|
||||
|
||||
# Mark entity positions with colored indicators
|
||||
for i, entity in enumerate(entities):
|
||||
marker = mcrfpy.Caption(pos=(120 + int(entity.x) * 40 + 15, 60 + int(entity.y) * 40 + 10),
|
||||
text=str(i+1))
|
||||
ex, ey = entity_cell(entity)
|
||||
marker = mcrfpy.Caption(pos=(120 + ex * 40 + 15, 60 + ey * 40 + 10), text=str(i+1))
|
||||
marker.fill_color = ENTITY_COLORS[i]
|
||||
marker.outline = 1
|
||||
marker.outline_color = mcrfpy.Color(0, 0, 0)
|
||||
|
|
@ -243,4 +263,77 @@ dijkstra_interactive.activate()
|
|||
print("\nVisualization ready!")
|
||||
print("Entities are at:")
|
||||
for i, entity in enumerate(entities):
|
||||
print(f" Entity {i+1}: ({int(entity.x)}, {int(entity.y)})")
|
||||
print(f" Entity {i+1}: {entity_cell(entity)}")
|
||||
|
||||
# --- Verification: drive the same selection logic the keyboard would ---
|
||||
|
||||
check(len(entities) == 3, f"expected 3 entities from map layout, got {len(entities)}")
|
||||
check(entity_cell(entities[0]) == (10, 2), f"entity 1 at {entity_cell(entities[0])}, expected (10, 2)")
|
||||
check(entity_cell(entities[1]) == (6, 4), f"entity 2 at {entity_cell(entities[1])}, expected (6, 4)")
|
||||
check(entity_cell(entities[2]) == (0, 5), f"entity 3 at {entity_cell(entities[2])}, expected (0, 5)")
|
||||
|
||||
def press(key):
|
||||
handle_keypress(key, mcrfpy.InputState.PRESSED)
|
||||
|
||||
# Entity 2 -> Entity 3: reachable, but only by going around the long x=2 wall.
|
||||
print("\nSelect: first=2 (key '2'), second=C (entity 3)")
|
||||
press(mcrfpy.Key.NUM_2)
|
||||
press(mcrfpy.Key.C)
|
||||
path_2_3 = last_path
|
||||
check(path_2_3,"expected a Dijkstra path between entity 2 (6,4) and entity 3 (0,5)")
|
||||
if path_2_3:
|
||||
# The map is ROOTED at the first selection (entity 2) and queried FROM the second
|
||||
# (entity 3), so the walk runs entity3 -> entity2: per #375 it excludes its origin
|
||||
# (0,5) and ends at the root (6,4).
|
||||
check(path_2_3[-1] == (6, 4),
|
||||
f"path should end at the root, entity 2 (6,4); ended at {path_2_3[-1]}")
|
||||
check((0, 5) not in path_2_3, "path should exclude its origin, entity 3 (0,5)")
|
||||
check(all(grid_data.at(x, y).walkable for x, y in path_2_3),
|
||||
"Dijkstra path crosses a non-walkable cell")
|
||||
# The wall at x=2 spans rows 1..8, so the path must detour through row 0 or row 9
|
||||
check(any(y in (0, 9) for x, y in path_2_3),
|
||||
"path should detour around the x=2 wall via row 0 or row 9")
|
||||
# Path cells must have been repainted with PATH_COLOR (except the entity endpoints)
|
||||
mid = path_2_3[len(path_2_3) // 2]
|
||||
if mid not in ((0, 5), (6, 4)):
|
||||
c = color_layer.at(*mid)
|
||||
check((c.r, c.g, c.b) == (PATH_COLOR.r, PATH_COLOR.g, PATH_COLOR.b),
|
||||
f"path cell {mid} not highlighted with PATH_COLOR, got ({c.r},{c.g},{c.b})")
|
||||
ec = color_layer.at(0, 5)
|
||||
check((ec.r, ec.g, ec.b) == (ENTITY_COLORS[2].r, ENTITY_COLORS[2].g, ENTITY_COLORS[2].b),
|
||||
"destination entity cell not painted with its entity color")
|
||||
|
||||
# Entity 1 is sealed inside the walled room: no path in or out.
|
||||
print("\nSelect: first=1 (key '1'), second=C (entity 3)")
|
||||
press(mcrfpy.Key.NUM_1)
|
||||
press(mcrfpy.Key.C)
|
||||
path_1_3 = last_path
|
||||
check(not path_1_3, f"entity 1 is walled in; expected no path to entity 3, got {path_1_3}")
|
||||
check("No path" in info_text.text, f"info text should report no path, got {info_text.text!r}")
|
||||
|
||||
grid_data.clear_dijkstra_maps()
|
||||
d_sealed = grid_data.get_dijkstra_map(root=entity_cell(entities[0])).distance(entity_cell(entities[1]))
|
||||
check(d_sealed is None, f"distance from sealed entity 1 to entity 2 should be None, got {d_sealed}")
|
||||
|
||||
# Space clears the selection and restores floor colors
|
||||
print("\nSelect: Space (clear)")
|
||||
press(mcrfpy.Key.SPACE)
|
||||
check(first_point is None and second_point is None, "Space should clear both selections")
|
||||
fc = color_layer.at(0, 0)
|
||||
check((fc.r, fc.g, fc.b) == (FLOOR_COLOR.r, FLOOR_COLOR.g, FLOOR_COLOR.b),
|
||||
"clear_path_highlight should restore FLOOR_COLOR on walkable cells")
|
||||
wc = color_layer.at(2, 1)
|
||||
check((wc.r, wc.g, wc.b) == (WALL_COLOR.r, WALL_COLOR.g, WALL_COLOR.b),
|
||||
"wall cells must keep WALL_COLOR after clearing the path")
|
||||
|
||||
# Render once (headless render is free of sim time) to prove the scene draws.
|
||||
press(mcrfpy.Key.NUM_2)
|
||||
press(mcrfpy.Key.C)
|
||||
automation.screenshot("dijkstra_interactive.png")
|
||||
|
||||
if failures:
|
||||
print(f"\nFAIL: {len(failures)} check(s) failed")
|
||||
sys.exit(1)
|
||||
|
||||
print("\nPASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,36 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enhanced Dijkstra Pathfinding Interactive Demo
|
||||
==============================================
|
||||
Enhanced Dijkstra Pathfinding Interactive Demo (headless-driven test)
|
||||
====================================================================
|
||||
|
||||
Interactive visualization with entity pathfinding animations.
|
||||
|
||||
Controls:
|
||||
Controls (when run with a window):
|
||||
- Press 1/2/3 to select the first entity
|
||||
- Press A/B/C to select the second entity
|
||||
- Press A/B/C to select the second entity
|
||||
- Space to clear selection
|
||||
- M to make selected entity move along path
|
||||
- P to pause/resume animation
|
||||
- R to reset entity positions
|
||||
- Q or ESC to quit
|
||||
|
||||
Under --headless --exec the same handlers are driven programmatically and the
|
||||
resulting paths / animation / reset are asserted (see run_checks() at the bottom).
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
import math
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
if condition:
|
||||
print(f" PASS: {label}")
|
||||
else:
|
||||
print(f" FAIL: {label} {detail}")
|
||||
failures.append(label)
|
||||
|
||||
|
||||
# Colors
|
||||
WALL_COLOR = mcrfpy.Color(60, 30, 30)
|
||||
|
|
@ -30,6 +43,9 @@ ENTITY_COLORS = [
|
|||
mcrfpy.Color(100, 100, 255), # Entity 3 - Blue
|
||||
]
|
||||
|
||||
GRID_W = 14
|
||||
GRID_H = 10
|
||||
|
||||
# Global state
|
||||
grid = None
|
||||
color_layer = None
|
||||
|
|
@ -42,33 +58,33 @@ animation_progress = 0.0
|
|||
animation_speed = 2.0 # cells per second
|
||||
original_positions = [] # Store original entity positions
|
||||
|
||||
# Define the map layout from user's specification
|
||||
# . = floor, W = wall, E = entity position
|
||||
map_layout = [
|
||||
"..............", # Row 0
|
||||
"..W.....WWWW..", # Row 1
|
||||
"..W.W...W.EW..", # Row 2 (entity 1 is sealed inside the room)
|
||||
"..W.....W..W..", # Row 3
|
||||
"..W...E.WWWW..", # Row 4
|
||||
"E.W...........", # Row 5
|
||||
"..W...........", # Row 6
|
||||
"..W...........", # Row 7
|
||||
"..W.WWW.......", # Row 8
|
||||
"..............", # Row 9
|
||||
]
|
||||
|
||||
|
||||
def create_map():
|
||||
"""Create the interactive map with the layout specified by the user"""
|
||||
global grid, color_layer, entities, original_positions
|
||||
|
||||
dijkstra_enhanced = mcrfpy.Scene("dijkstra_enhanced")
|
||||
|
||||
# Create grid - 14x10 as specified
|
||||
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
|
||||
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H))
|
||||
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||
|
||||
# Add color layer for cell coloring
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
|
||||
# Define the map layout from user's specification
|
||||
# . = floor, W = wall, E = entity position
|
||||
map_layout = [
|
||||
"..............", # Row 0
|
||||
"..W.....WWWW..", # Row 1
|
||||
"..W.W...W.EW..", # Row 2
|
||||
"..W.....W..W..", # Row 3
|
||||
"..W...E.WWWW..", # Row 4
|
||||
"E.W...........", # Row 5
|
||||
"..W...........", # Row 6
|
||||
"..W...........", # Row 7
|
||||
"..W.WWW.......", # Row 8
|
||||
"..............", # Row 9
|
||||
]
|
||||
# Add color layer for cell coloring (GridPoint.color is gone; use a ColorLayer)
|
||||
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||
grid.add_layer(color_layer)
|
||||
|
||||
# Create the map
|
||||
entity_positions = []
|
||||
|
|
@ -80,12 +96,12 @@ def create_map():
|
|||
# Wall
|
||||
cell.walkable = False
|
||||
cell.transparent = False
|
||||
color_layer.set(x, y, WALL_COLOR)
|
||||
color_layer.set((x, y), WALL_COLOR)
|
||||
else:
|
||||
# Floor
|
||||
cell.walkable = True
|
||||
cell.transparent = True
|
||||
color_layer.set(x, y, FLOOR_COLOR)
|
||||
color_layer.set((x, y), FLOOR_COLOR)
|
||||
|
||||
if char == 'E':
|
||||
# Entity position
|
||||
|
|
@ -95,26 +111,28 @@ def create_map():
|
|||
entities = []
|
||||
original_positions = []
|
||||
for i, (x, y) in enumerate(entity_positions):
|
||||
entity = mcrfpy.Entity((x, y), grid=grid)
|
||||
entity = mcrfpy.Entity(grid_pos=(x, y), grid=grid)
|
||||
entity.sprite_index = 49 + i # '1', '2', '3'
|
||||
entities.append(entity)
|
||||
original_positions.append((x, y))
|
||||
|
||||
return grid
|
||||
|
||||
|
||||
def clear_path_highlight():
|
||||
"""Clear any existing path highlighting"""
|
||||
global current_path
|
||||
|
||||
# Reset all floor tiles to original color
|
||||
for y in range(grid.grid_h):
|
||||
for x in range(grid.grid_w):
|
||||
for y in range(GRID_H):
|
||||
for x in range(GRID_W):
|
||||
cell = grid.at(x, y)
|
||||
if cell.walkable:
|
||||
color_layer.set(x, y, FLOOR_COLOR)
|
||||
color_layer.set((x, y), FLOOR_COLOR)
|
||||
|
||||
current_path = []
|
||||
|
||||
|
||||
def highlight_path():
|
||||
"""Highlight the path between selected entities using entity.path_to()"""
|
||||
global current_path
|
||||
|
|
@ -129,8 +147,8 @@ def highlight_path():
|
|||
entity1 = entities[first_point]
|
||||
entity2 = entities[second_point]
|
||||
|
||||
# Use the new path_to method!
|
||||
path = entity1.path_to(int(entity2.x), int(entity2.y))
|
||||
# Use the path_to method! (entity.x/.y are PIXELS now; cells are grid_x/grid_y)
|
||||
path = entity1.path_to(entity2.grid_x, entity2.grid_y)
|
||||
|
||||
if path:
|
||||
current_path = path
|
||||
|
|
@ -141,13 +159,13 @@ def highlight_path():
|
|||
if cell.walkable:
|
||||
# Use gradient for path visualization
|
||||
if i < len(path) - 1:
|
||||
color_layer.set(x, y, PATH_COLOR)
|
||||
color_layer.set((x, y), PATH_COLOR)
|
||||
else:
|
||||
color_layer.set(x, y, VISITED_COLOR)
|
||||
color_layer.set((x, y), VISITED_COLOR)
|
||||
|
||||
# Highlight start and end with entity colors
|
||||
color_layer.set(int(entity1.x), int(entity1.y), ENTITY_COLORS[first_point])
|
||||
color_layer.set(int(entity2.x), int(entity2.y), ENTITY_COLORS[second_point])
|
||||
color_layer.set((entity1.grid_x, entity1.grid_y), ENTITY_COLORS[first_point])
|
||||
color_layer.set((entity2.grid_x, entity2.grid_y), ENTITY_COLORS[second_point])
|
||||
|
||||
# Update info
|
||||
info_text.text = f"Path: Entity {first_point+1} to Entity {second_point+1} - {len(path)} steps"
|
||||
|
|
@ -155,102 +173,96 @@ def highlight_path():
|
|||
info_text.text = f"No path between Entity {first_point+1} and Entity {second_point+1}"
|
||||
current_path = []
|
||||
|
||||
|
||||
def animate_movement(dt):
|
||||
"""Animate entity movement along path"""
|
||||
global animation_progress, animating, current_path
|
||||
|
||||
|
||||
if not animating or not current_path or first_point is None:
|
||||
return
|
||||
|
||||
|
||||
entity = entities[first_point]
|
||||
|
||||
|
||||
# Update animation progress
|
||||
animation_progress += animation_speed * dt
|
||||
|
||||
|
||||
# Calculate current position along path
|
||||
path_index = int(animation_progress)
|
||||
|
||||
|
||||
if path_index >= len(current_path):
|
||||
# Animation complete
|
||||
animating = False
|
||||
animation_progress = 0.0
|
||||
# Snap to final position
|
||||
# Snap to final position (grid_pos = logical cell, draw_pos = visual)
|
||||
if current_path:
|
||||
final_x, final_y = current_path[-1]
|
||||
entity.x = float(final_x)
|
||||
entity.y = float(final_y)
|
||||
entity.grid_pos = (final_x, final_y)
|
||||
entity.draw_pos = (float(final_x), float(final_y))
|
||||
return
|
||||
|
||||
|
||||
# Interpolate between path points
|
||||
if path_index < len(current_path) - 1:
|
||||
curr_x, curr_y = current_path[path_index]
|
||||
next_x, next_y = current_path[path_index + 1]
|
||||
|
||||
|
||||
# Calculate interpolation factor
|
||||
t = animation_progress - path_index
|
||||
|
||||
# Smooth interpolation
|
||||
entity.x = curr_x + (next_x - curr_x) * t
|
||||
entity.y = curr_y + (next_y - curr_y) * t
|
||||
|
||||
# Smooth interpolation (draw_pos is the fractional render position)
|
||||
entity.draw_pos = (curr_x + (next_x - curr_x) * t,
|
||||
curr_y + (next_y - curr_y) * t)
|
||||
entity.grid_pos = (curr_x, curr_y)
|
||||
else:
|
||||
# At last point
|
||||
entity.x, entity.y = current_path[path_index]
|
||||
entity.grid_pos = current_path[path_index]
|
||||
entity.draw_pos = (float(current_path[path_index][0]),
|
||||
float(current_path[path_index][1]))
|
||||
|
||||
def handle_keypress(scene_name, keycode):
|
||||
"""Handle keyboard input"""
|
||||
|
||||
def handle_keypress(key, action):
|
||||
"""Handle keyboard input (#184: handlers receive Key and InputState enums)"""
|
||||
global first_point, second_point, animating, animation_progress
|
||||
|
||||
|
||||
if action != mcrfpy.InputState.PRESSED:
|
||||
return
|
||||
|
||||
# Number keys for first entity
|
||||
if keycode == 49: # '1'
|
||||
first_point = 0
|
||||
status_text.text = f"First: Entity 1 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
|
||||
if key in (mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3):
|
||||
first_point = {mcrfpy.Key.NUM_1: 0, mcrfpy.Key.NUM_2: 1, mcrfpy.Key.NUM_3: 2}[key]
|
||||
status_text.text = f"First: Entity {first_point+1} | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
|
||||
highlight_path()
|
||||
elif keycode == 50: # '2'
|
||||
first_point = 1
|
||||
status_text.text = f"First: Entity 2 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
|
||||
highlight_path()
|
||||
elif keycode == 51: # '3'
|
||||
first_point = 2
|
||||
status_text.text = f"First: Entity 3 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
|
||||
highlight_path()
|
||||
|
||||
|
||||
# Letter keys for second entity
|
||||
elif keycode == 65 or keycode == 97: # 'A' or 'a'
|
||||
second_point = 0
|
||||
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 1"
|
||||
elif key in (mcrfpy.Key.A, mcrfpy.Key.B, mcrfpy.Key.C):
|
||||
second_point = {mcrfpy.Key.A: 0, mcrfpy.Key.B: 1, mcrfpy.Key.C: 2}[key]
|
||||
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity {second_point+1}"
|
||||
highlight_path()
|
||||
elif keycode == 66 or keycode == 98: # 'B' or 'b'
|
||||
second_point = 1
|
||||
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 2"
|
||||
highlight_path()
|
||||
elif keycode == 67 or keycode == 99: # 'C' or 'c'
|
||||
second_point = 2
|
||||
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 3"
|
||||
highlight_path()
|
||||
|
||||
|
||||
# Movement control
|
||||
elif keycode == 77 or keycode == 109: # 'M' or 'm'
|
||||
elif key == mcrfpy.Key.M:
|
||||
if current_path and first_point is not None:
|
||||
animating = True
|
||||
animation_progress = 0.0
|
||||
control_text.text = "Animation: MOVING (press P to pause)"
|
||||
|
||||
|
||||
# Pause/Resume
|
||||
elif keycode == 80 or keycode == 112: # 'P' or 'p'
|
||||
elif key == mcrfpy.Key.P:
|
||||
animating = not animating
|
||||
control_text.text = f"Animation: {'MOVING' if animating else 'PAUSED'} (press P to {'pause' if animating else 'resume'})"
|
||||
|
||||
|
||||
# Reset positions
|
||||
elif keycode == 82 or keycode == 114: # 'R' or 'r'
|
||||
elif key == mcrfpy.Key.R:
|
||||
animating = False
|
||||
animation_progress = 0.0
|
||||
for i, entity in enumerate(entities):
|
||||
entity.x, entity.y = original_positions[i]
|
||||
entity.grid_pos = original_positions[i]
|
||||
entity.draw_pos = (float(original_positions[i][0]),
|
||||
float(original_positions[i][1]))
|
||||
control_text.text = "Entities reset to original positions"
|
||||
highlight_path() # Re-highlight path after reset
|
||||
|
||||
|
||||
# Clear selection
|
||||
elif keycode == 32: # Space
|
||||
elif key == mcrfpy.Key.SPACE:
|
||||
first_point = None
|
||||
second_point = None
|
||||
animating = False
|
||||
|
|
@ -259,16 +271,27 @@ def handle_keypress(scene_name, keycode):
|
|||
status_text.text = "Press 1/2/3 for first entity, A/B/C for second"
|
||||
info_text.text = "Space to clear, Q to quit"
|
||||
control_text.text = "Press M to move, P to pause, R to reset"
|
||||
|
||||
# Quit
|
||||
elif keycode == 81 or keycode == 113 or keycode == 256: # Q/q/ESC
|
||||
print("\nExiting enhanced Dijkstra demo...")
|
||||
sys.exit(0)
|
||||
|
||||
# Timer callback for animation
|
||||
def update_animation(timer, dt):
|
||||
# Quit
|
||||
elif key in (mcrfpy.Key.Q, mcrfpy.Key.ESCAPE):
|
||||
print("\nExiting enhanced Dijkstra demo...")
|
||||
sys.exit(0 if not failures else 1)
|
||||
|
||||
|
||||
# Timer callback for animation. Callback is (timer, runtime_ms) -- runtime is
|
||||
# cumulative, so derive the frame delta ourselves.
|
||||
last_runtime_ms = 0.0
|
||||
timer_ticks = 0
|
||||
|
||||
|
||||
def update_animation(timer, runtime_ms):
|
||||
"""Update animation state"""
|
||||
animate_movement(dt / 1000.0) # Convert ms to seconds
|
||||
global last_runtime_ms, timer_ticks
|
||||
dt_ms = runtime_ms - last_runtime_ms
|
||||
last_runtime_ms = runtime_ms
|
||||
timer_ticks += 1
|
||||
animate_movement(dt_ms / 1000.0) # Convert ms to seconds
|
||||
|
||||
|
||||
# Create the visualization
|
||||
print("Enhanced Dijkstra Pathfinding Demo")
|
||||
|
|
@ -282,6 +305,8 @@ print(" R - Reset entity positions")
|
|||
print(" Space - Clear selection")
|
||||
print(" Q/ESC - Quit")
|
||||
|
||||
dijkstra_enhanced = mcrfpy.Scene("dijkstra_enhanced")
|
||||
|
||||
# Create map
|
||||
grid = create_map()
|
||||
|
||||
|
|
@ -324,7 +349,7 @@ ui.append(legend2)
|
|||
|
||||
# Mark entity positions with colored indicators
|
||||
for i, entity in enumerate(entities):
|
||||
marker = mcrfpy.Caption(pos=(120 + int(entity.x) * 40 + 15, 60 + int(entity.y) * 40 + 10),
|
||||
marker = mcrfpy.Caption(pos=(120 + entity.grid_x * 40 + 15, 60 + entity.grid_y * 40 + 10),
|
||||
text=str(i+1))
|
||||
marker.fill_color = ENTITY_COLORS[i]
|
||||
marker.outline = 1
|
||||
|
|
@ -343,4 +368,86 @@ dijkstra_enhanced.activate()
|
|||
print("\nVisualization ready!")
|
||||
print("Entities are at:")
|
||||
for i, entity in enumerate(entities):
|
||||
print(f" Entity {i+1}: ({int(entity.x)}, {int(entity.y)})")
|
||||
print(f" Entity {i+1}: ({entity.grid_x}, {entity.grid_y})")
|
||||
|
||||
|
||||
def press(key):
|
||||
"""Simulate a key press through the scene's real handler"""
|
||||
handle_keypress(key, mcrfpy.InputState.PRESSED)
|
||||
|
||||
|
||||
def run_checks():
|
||||
"""Headless driver: exercise the same code paths the keyboard drives."""
|
||||
print("\n1. Map construction")
|
||||
check("three entities placed at 'E' markers", len(entities) == 3,
|
||||
f"got {len(entities)}")
|
||||
check("entity positions match layout",
|
||||
original_positions == [(10, 2), (6, 4), (0, 5)],
|
||||
f"got {original_positions}")
|
||||
check("walls are not walkable", not grid.at(2, 1).walkable)
|
||||
check("floors are walkable", grid.at(0, 0).walkable)
|
||||
# grid.layer(name) returns a wrapper around the same layer (not the same PyObject)
|
||||
check("color layer attached", grid.layer("color").name == "color")
|
||||
|
||||
print("\n2. path_to() between entity 2 and entity 3 (reachable around the wall)")
|
||||
press(mcrfpy.Key.NUM_2) # first = entity 2 @ (6,4)
|
||||
press(mcrfpy.Key.C) # second = entity 3 @ (0,5)
|
||||
check("path found", len(current_path) > 0, "path_to returned empty")
|
||||
if current_path:
|
||||
check("path ends at entity 3", tuple(current_path[-1]) == (0, 5),
|
||||
f"got {current_path[-1]}")
|
||||
walkable = all(grid.at(x, y).walkable for x, y in current_path)
|
||||
check("every path cell is walkable", walkable)
|
||||
contiguous = all(
|
||||
max(abs(current_path[i+1][0] - current_path[i][0]),
|
||||
abs(current_path[i+1][1] - current_path[i][1])) == 1
|
||||
for i in range(len(current_path) - 1))
|
||||
check("path steps are contiguous", contiguous)
|
||||
check("info caption reports the path", "Path:" in info_text.text,
|
||||
info_text.text)
|
||||
|
||||
print("\n3. path_to() into the sealed room (entity 1) has no solution")
|
||||
saved_path = list(current_path)
|
||||
press(mcrfpy.Key.A) # second = entity 1 @ (10,2), walled in
|
||||
check("no path reported", len(current_path) == 0,
|
||||
f"got {len(current_path)} steps")
|
||||
check("info caption reports no path", "No path" in info_text.text,
|
||||
info_text.text)
|
||||
|
||||
print("\n4. M drives the entity along the path (timer + step())")
|
||||
press(mcrfpy.Key.C) # back to the reachable target
|
||||
check("path restored", current_path == saved_path)
|
||||
press(mcrfpy.Key.M)
|
||||
check("animating flag set", animating is True)
|
||||
|
||||
# mcrfpy.step() is the headless clock; the 16ms timer fires once per step.
|
||||
for _ in range(400):
|
||||
mcrfpy.step(0.05)
|
||||
if not animating:
|
||||
break
|
||||
check("animation timer fired", timer_ticks > 0, f"ticks={timer_ticks}")
|
||||
check("animation completed", animating is False)
|
||||
entity2 = entities[1]
|
||||
check("entity 2 arrived at entity 3's cell",
|
||||
(entity2.grid_x, entity2.grid_y) == (0, 5),
|
||||
f"got ({entity2.grid_x}, {entity2.grid_y})")
|
||||
|
||||
print("\n5. R resets entities to their original positions")
|
||||
press(mcrfpy.Key.R)
|
||||
positions = [(e.grid_x, e.grid_y) for e in entities]
|
||||
check("positions restored", positions == original_positions,
|
||||
f"got {positions}")
|
||||
|
||||
print("\n6. Space clears the selection and the highlight")
|
||||
press(mcrfpy.Key.SPACE)
|
||||
check("selection cleared",
|
||||
first_point is None and second_point is None and current_path == [])
|
||||
|
||||
|
||||
run_checks()
|
||||
|
||||
if failures:
|
||||
print(f"\nFAIL: {len(failures)} check(s) failed: {failures}")
|
||||
sys.exit(1)
|
||||
print("\nPASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -4,27 +4,45 @@ Dijkstra Pathfinding Test - Headless
|
|||
====================================
|
||||
|
||||
Tests all Dijkstra functionality and generates a screenshot.
|
||||
|
||||
API notes (updated for the current engine):
|
||||
- Dijkstra maps come from grid.get_dijkstra_map(root=...) -> DijkstraMap.
|
||||
The old compute_dijkstra / get_dijkstra_distance / get_dijkstra_path
|
||||
methods are gone; DijkstraMap.distance(pos) and .path_from(pos) replace them.
|
||||
- GridPoint has no .color; per-cell coloring is done with a ColorLayer.
|
||||
- Entity.x/.y are pixel coordinates; the logical cell is entity.cell_pos.
|
||||
- Headless has no clock of its own: mcrfpy.step(dt) drives timers.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
import sys
|
||||
|
||||
failures = []
|
||||
|
||||
def check(cond, msg):
|
||||
if not cond:
|
||||
failures.append(msg)
|
||||
print(f"FAIL: {msg}")
|
||||
return cond
|
||||
|
||||
def create_test_map():
|
||||
"""Create a test map with obstacles"""
|
||||
dijkstra_test = mcrfpy.Scene("dijkstra_test")
|
||||
|
||||
# Create grid
|
||||
grid = mcrfpy.Grid(grid_w=20, grid_h=12)
|
||||
grid = mcrfpy.Grid(grid_size=(20, 12))
|
||||
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||
|
||||
|
||||
# Per-cell coloring now lives on a ColorLayer, not on GridPoint
|
||||
color_layer = mcrfpy.ColorLayer(name="cells")
|
||||
grid.add_layer(color_layer)
|
||||
|
||||
# Initialize all cells as walkable floor
|
||||
for y in range(12):
|
||||
for x in range(20):
|
||||
grid.at(x, y).walkable = True
|
||||
grid.at(x, y).transparent = True
|
||||
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
|
||||
|
||||
color_layer.set((x, y), mcrfpy.Color(200, 200, 220))
|
||||
|
||||
# Add walls to create interesting paths
|
||||
walls = [
|
||||
# Vertical wall in the middle
|
||||
|
|
@ -35,11 +53,11 @@ def create_test_map():
|
|||
# Some scattered obstacles
|
||||
(5, 2), (15, 2), (5, 9), (15, 9)
|
||||
]
|
||||
|
||||
|
||||
for x, y in walls:
|
||||
grid.at(x, y).walkable = False
|
||||
grid.at(x, y).color = mcrfpy.Color(60, 30, 30)
|
||||
|
||||
color_layer.set((x, y), mcrfpy.Color(60, 30, 30))
|
||||
|
||||
# Place test entities
|
||||
entities = []
|
||||
positions = [(2, 2), (17, 2), (9, 10)]
|
||||
|
|
@ -48,78 +66,125 @@ def create_test_map():
|
|||
mcrfpy.Color(100, 255, 100), # Green
|
||||
mcrfpy.Color(100, 100, 255) # Blue
|
||||
]
|
||||
|
||||
|
||||
for i, (x, y) in enumerate(positions):
|
||||
entity = mcrfpy.Entity(x, y)
|
||||
entity = mcrfpy.Entity(grid_pos=(x, y))
|
||||
entity.sprite_index = 49 + i # '1', '2', '3'
|
||||
grid.entities.append(entity)
|
||||
entities.append(entity)
|
||||
# Mark entity positions
|
||||
grid.at(x, y).color = colors[i]
|
||||
|
||||
return grid, entities
|
||||
color_layer.set((x, y), colors[i])
|
||||
|
||||
def test_dijkstra(grid, entities):
|
||||
# Walkability was mutated after grid construction; drop any cached maps
|
||||
grid.clear_dijkstra_maps()
|
||||
|
||||
return grid, color_layer, entities, walls
|
||||
|
||||
|
||||
def test_dijkstra(grid, color_layer, entities, walls):
|
||||
"""Test Dijkstra pathfinding between all entity pairs"""
|
||||
results = []
|
||||
|
||||
|
||||
for i in range(len(entities)):
|
||||
for j in range(len(entities)):
|
||||
if i != j:
|
||||
# Compute Dijkstra from entity i
|
||||
e1 = entities[i]
|
||||
e2 = entities[j]
|
||||
grid.compute_dijkstra(int(e1.x), int(e1.y))
|
||||
|
||||
src = (int(e1.cell_pos.x), int(e1.cell_pos.y))
|
||||
dst = (int(e2.cell_pos.x), int(e2.cell_pos.y))
|
||||
|
||||
dmap = grid.get_dijkstra_map(root=src)
|
||||
check(dmap.root == mcrfpy.Vector(src[0], src[1]),
|
||||
f"dijkstra map root should be {src}, got {dmap.root}")
|
||||
|
||||
# Get distance and path to entity j
|
||||
distance = grid.get_dijkstra_distance(int(e2.x), int(e2.y))
|
||||
path = grid.get_dijkstra_path(int(e2.x), int(e2.y))
|
||||
|
||||
distance = dmap.distance(dst)
|
||||
path = list(dmap.path_from(dst))
|
||||
|
||||
check(distance is not None, f"no distance from {src} to {dst}")
|
||||
check(len(path) > 0, f"no path from {src} to {dst}")
|
||||
|
||||
if path:
|
||||
# Root is reachable and the path is a real, connected walk
|
||||
check(distance > 0, f"distance {src}->{dst} should be positive")
|
||||
# Number of steps can't be fewer than the Chebyshev distance
|
||||
cheb = max(abs(src[0] - dst[0]), abs(src[1] - dst[1]))
|
||||
check(len(path) >= cheb,
|
||||
f"path {src}->{dst} too short: {len(path)} < {cheb}")
|
||||
|
||||
cells = [(int(v.x), int(v.y)) for v in path]
|
||||
for cx, cy in cells:
|
||||
check(grid.at(cx, cy).walkable,
|
||||
f"path {src}->{dst} crosses unwalkable cell {(cx, cy)}")
|
||||
check((cx, cy) not in walls,
|
||||
f"path {src}->{dst} crosses wall {(cx, cy)}")
|
||||
|
||||
# Consecutive path cells must be adjacent (8-way)
|
||||
for a, b in zip(cells, cells[1:]):
|
||||
check(max(abs(a[0] - b[0]), abs(a[1] - b[1])) == 1,
|
||||
f"path {src}->{dst} jumps from {a} to {b}")
|
||||
|
||||
# The map is ROOTED at src and queried FROM dst, so the walk runs
|
||||
# dst -> src. Per the #375 convention (shared with find_path) it
|
||||
# EXCLUDES its origin (dst) and ENDS at its destination (src).
|
||||
check(cells[-1] == src,
|
||||
f"path from {dst} should end at the root {src}, ended at {cells[-1]}")
|
||||
check(dst not in cells,
|
||||
f"path from {dst} should exclude its origin {dst}")
|
||||
check(max(abs(cells[0][0] - dst[0]), abs(cells[0][1] - dst[1])) == 1,
|
||||
f"path from {dst} should begin one step away, began at {cells[0]}")
|
||||
|
||||
results.append(f"Path {i+1}→{j+1}: {len(path)} steps, {distance:.1f} units")
|
||||
|
||||
|
||||
# Color one interesting path
|
||||
if i == 0 and j == 2: # Path from 1 to 3
|
||||
for x, y in path[1:-1]: # Skip endpoints
|
||||
if grid.at(x, y).walkable:
|
||||
grid.at(x, y).color = mcrfpy.Color(200, 250, 220)
|
||||
for cx, cy in cells:
|
||||
if (cx, cy) not in (src, dst) and grid.at(cx, cy).walkable:
|
||||
color_layer.set((cx, cy), mcrfpy.Color(200, 250, 220))
|
||||
else:
|
||||
results.append(f"Path {i+1}→{j+1}: No path found!")
|
||||
|
||||
|
||||
# Walls are unreachable: distance must be None, not a bogus number
|
||||
root = (int(entities[0].cell_pos.x), int(entities[0].cell_pos.y))
|
||||
dmap = grid.get_dijkstra_map(root=root)
|
||||
check(dmap.distance((10, 4)) is None,
|
||||
"distance to an unwalkable wall cell should be None")
|
||||
check(dmap.distance(root) == 0.0,
|
||||
f"distance from root to itself should be 0, got {dmap.distance(root)}")
|
||||
|
||||
# Dijkstra distances are symmetric on a uniform-cost grid
|
||||
a = (int(entities[0].cell_pos.x), int(entities[0].cell_pos.y))
|
||||
b = (int(entities[1].cell_pos.x), int(entities[1].cell_pos.y))
|
||||
d_ab = grid.get_dijkstra_map(root=a).distance(b)
|
||||
d_ba = grid.get_dijkstra_map(root=b).distance(a)
|
||||
check(abs(d_ab - d_ba) < 0.01,
|
||||
f"distance should be symmetric: {a}->{b}={d_ab}, {b}->{a}={d_ba}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def run_test(timer, runtime):
|
||||
"""Timer callback to run tests and take screenshot"""
|
||||
# Run pathfinding tests
|
||||
results = test_dijkstra(grid, entities)
|
||||
"""Timer callback to run tests"""
|
||||
global results
|
||||
results = test_dijkstra(grid, color_layer, entities, walls)
|
||||
|
||||
# Update display with results
|
||||
y_pos = 380
|
||||
for result in results:
|
||||
caption = mcrfpy.Caption(result, 50, y_pos)
|
||||
caption = mcrfpy.Caption(text=result, pos=(50, y_pos))
|
||||
caption.fill_color = mcrfpy.Color(200, 200, 200)
|
||||
ui.append(caption)
|
||||
y_pos += 20
|
||||
|
||||
# Take screenshot (one-shot timer)
|
||||
screenshot_timer = mcrfpy.Timer("screenshot", lambda t, rt: take_screenshot(), 500, once=True)
|
||||
|
||||
def take_screenshot():
|
||||
"""Take screenshot and exit"""
|
||||
try:
|
||||
automation.screenshot("dijkstra_test.png")
|
||||
print("Screenshot saved: dijkstra_test.png")
|
||||
except Exception as e:
|
||||
print(f"Screenshot failed: {e}")
|
||||
|
||||
# Exit
|
||||
sys.exit(0)
|
||||
|
||||
# Create test map
|
||||
print("Creating Dijkstra pathfinding test...")
|
||||
grid, entities = create_test_map()
|
||||
grid, color_layer, entities, walls = create_test_map()
|
||||
results = None
|
||||
|
||||
# Set up UI
|
||||
dijkstra_test = mcrfpy.Scene("dijkstra_test")
|
||||
ui = dijkstra_test.children
|
||||
ui.append(grid)
|
||||
|
||||
|
|
@ -133,14 +198,40 @@ title.fill_color = mcrfpy.Color(255, 255, 255)
|
|||
ui.append(title)
|
||||
|
||||
# Add legend
|
||||
legend = mcrfpy.Caption(pos=(50, 360), text="Red=Entity1 Green=Entity2 Blue=Entity3 Cyan=Path 1→3")
|
||||
legend = mcrfpy.Caption(pos=(50, 360), text="Red=Entity1 Green=Entity2 Blue=Entity3 Cyan=Path 1->3")
|
||||
legend.fill_color = mcrfpy.Color(180, 180, 180)
|
||||
ui.append(legend)
|
||||
|
||||
# Set scene
|
||||
dijkstra_test.activate()
|
||||
|
||||
# Run test after scene loads (one-shot timer)
|
||||
# Run test after scene loads (one-shot timer). Headless has no clock of its own,
|
||||
# so drive the timer forward explicitly with mcrfpy.step().
|
||||
test_timer = mcrfpy.Timer("test", run_test, 100, once=True)
|
||||
print("Running Dijkstra tests...")
|
||||
for _ in range(10):
|
||||
mcrfpy.step(0.05)
|
||||
if results is not None:
|
||||
break
|
||||
|
||||
print("Running Dijkstra tests...")
|
||||
check(results is not None, "timer callback never fired; Dijkstra tests did not run")
|
||||
if results:
|
||||
for line in results:
|
||||
print(line)
|
||||
check(len(results) == 6, f"expected 6 entity-pair results, got {len(results)}")
|
||||
check(all("No path found" not in r for r in results),
|
||||
"every entity pair should be mutually reachable")
|
||||
|
||||
# Rendering is orthogonal to sim time; force a render for the screenshot
|
||||
try:
|
||||
automation.screenshot("dijkstra_test.png")
|
||||
print("Screenshot saved: dijkstra_test.png")
|
||||
except Exception as e:
|
||||
check(False, f"Screenshot failed: {e}")
|
||||
|
||||
if failures:
|
||||
print(f"FAILED ({len(failures)} checks)")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,29 +1,67 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Force Python to be non-interactive"""
|
||||
"""Verify that --exec scripts run Python non-interactively.
|
||||
|
||||
Historically this file *forced* non-interactive mode (deleting sys.ps1/ps2 and
|
||||
mangling termios) because the embedded interpreter could drop into a REPL and
|
||||
hang the engine. That workaround is gone; the property it was protecting is now
|
||||
an engine guarantee, so this asserts it instead of forcing it:
|
||||
|
||||
- the interpreter is not in REPL mode (no sys.ps1 / sys.ps2)
|
||||
- it will not drop into a REPL after the script (-i / inspect not set)
|
||||
- stdin is not an interactive terminal, and is not consumed by the engine
|
||||
- the script, not a prompt, decides when the process ends (#350 exit contract)
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import mcrfpy
|
||||
|
||||
print("Attempting to force non-interactive mode...")
|
||||
failures = []
|
||||
|
||||
# Remove ps1/ps2 if they exist
|
||||
if hasattr(sys, 'ps1'):
|
||||
delattr(sys, 'ps1')
|
||||
if hasattr(sys, 'ps2'):
|
||||
delattr(sys, 'ps2')
|
||||
|
||||
# Set environment variable
|
||||
os.environ['PYTHONSTARTUP'] = ''
|
||||
def check(label, condition, detail=""):
|
||||
if condition:
|
||||
print(" ok : %s" % label)
|
||||
else:
|
||||
print(" FAIL : %s %s" % (label, detail))
|
||||
failures.append(label)
|
||||
|
||||
# Try to set stdin to non-interactive
|
||||
try:
|
||||
import fcntl
|
||||
import termios
|
||||
# Make stdin non-interactive by removing ICANON flag
|
||||
attrs = termios.tcgetattr(0)
|
||||
attrs[3] = attrs[3] & ~termios.ICANON
|
||||
termios.tcsetattr(0, termios.TCSANOW, attrs)
|
||||
print("Modified terminal attributes")
|
||||
except:
|
||||
print("Could not modify terminal attributes")
|
||||
|
||||
print("Script complete")
|
||||
print("Checking non-interactive execution of --exec scripts...")
|
||||
|
||||
# 1. REPL prompts only exist when the interpreter is interactive. Their presence
|
||||
# would mean the engine handed us an interactive interpreter.
|
||||
check("sys.ps1 absent", not hasattr(sys, "ps1"))
|
||||
check("sys.ps2 absent", not hasattr(sys, "ps2"))
|
||||
|
||||
# 2. Interpreter flags: -i / PYTHONINSPECT would make Python drop to a prompt
|
||||
# *after* the script finishes, hanging a headless run.
|
||||
check("sys.flags.interactive == 0", sys.flags.interactive == 0,
|
||||
"got %r" % (sys.flags.interactive,))
|
||||
check("sys.flags.inspect == 0", sys.flags.inspect == 0,
|
||||
"got %r" % (sys.flags.inspect,))
|
||||
check("PYTHONINSPECT not set in env", not os.environ.get("PYTHONINSPECT"))
|
||||
|
||||
# 3. stdin must not be an interactive terminal driving a REPL.
|
||||
# NOTE: deliberately no blocking read here -- under a test runner stdin is an
|
||||
# open pipe that never sends EOF, so sys.stdin.read() would hang forever. The
|
||||
# thing worth asserting is that no prompt is bound to it.
|
||||
check("stdin is not a tty", sys.stdin is None or not sys.stdin.isatty())
|
||||
|
||||
# 4. The engine must not be running its own read-eval-print loop over our script:
|
||||
# module scope is ordinary script scope, and the headless clock only advances
|
||||
# when we ask it to (#350). One step() must not spin into an interactive loop.
|
||||
scene = mcrfpy.Scene("force_non_interactive")
|
||||
mcrfpy.current_scene = scene
|
||||
fired = []
|
||||
mcrfpy.Timer("tick", lambda timer, runtime: fired.append(runtime), 50)
|
||||
for _ in range(4):
|
||||
mcrfpy.step(0.05)
|
||||
check("headless clock advances under script control", len(fired) >= 1,
|
||||
"timer fired %d time(s)" % len(fired))
|
||||
|
||||
if failures:
|
||||
print("FAIL: %d check(s) failed: %s" % (len(failures), ", ".join(failures)))
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,40 +1,62 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Interactive Visibility Demo
|
||||
==========================
|
||||
Interactive Visibility Test
|
||||
===========================
|
||||
|
||||
Controls:
|
||||
- WASD: Move the player (green @)
|
||||
- Arrow keys: Move enemy (red E)
|
||||
- Tab: Cycle perspective (Omniscient → Player → Enemy → Omniscient)
|
||||
- Space: Update visibility for current entity
|
||||
- R: Reset positions
|
||||
Originally an interactive demo (WASD moves the player, arrows move the enemy,
|
||||
Tab cycles perspective, Space recomputes visibility, R resets). Headless mode
|
||||
never delivers real keystrokes, so the same key handler is now driven with
|
||||
mcrfpy.automation.keyDown/keyUp and the resulting state is asserted.
|
||||
|
||||
Covers:
|
||||
- walls block movement (walkable) and sight (transparent)
|
||||
- Entity.update_visibility() / Entity.perspective_map (UNKNOWN/DISCOVERED/VISIBLE)
|
||||
- moving an entity discovers new cells and demotes VISIBLE -> DISCOVERED
|
||||
- Grid.perspective cycling (None -> player -> enemy -> None)
|
||||
|
||||
API notes (current contract):
|
||||
- grid.add_layer() takes a layer OBJECT, no kwargs; GridPoint has no .color,
|
||||
so cell coloring goes through a ColorLayer.
|
||||
- Grid.perspective is an Entity (or None), not an integer index.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
import sys
|
||||
|
||||
GRID_W, GRID_H = 30, 20
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
if not cond:
|
||||
failures.append(msg)
|
||||
print(f"FAIL: {msg}")
|
||||
return cond
|
||||
|
||||
|
||||
# Create scene and grid
|
||||
visibility_demo = mcrfpy.Scene("visibility_demo")
|
||||
grid = mcrfpy.Grid(grid_w=30, grid_h=20)
|
||||
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H), pos=(50, 100), size=(900, 600))
|
||||
grid.fill_color = mcrfpy.Color(20, 20, 30) # Dark background
|
||||
|
||||
# Add color layer for cell coloring
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
# Add color layer for cell coloring (GridPoint.color no longer exists)
|
||||
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||
grid.grid_data.add_layer(color_layer)
|
||||
|
||||
# Initialize grid - all walkable and transparent
|
||||
for y in range(20):
|
||||
for x in range(30):
|
||||
for y in range(GRID_H):
|
||||
for x in range(GRID_W):
|
||||
cell = grid.at(x, y)
|
||||
cell.walkable = True
|
||||
cell.transparent = True
|
||||
color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) # Floor color
|
||||
color_layer.set((x, y), mcrfpy.Color(100, 100, 120)) # Floor color
|
||||
|
||||
# Create walls
|
||||
walls = [
|
||||
# Central cross
|
||||
[(15, y) for y in range(8, 12)],
|
||||
[(x, 10) for x in range(13, 18)],
|
||||
# Central cross - a solid wall separating left half from right half
|
||||
[(15, y) for y in range(0, GRID_H)],
|
||||
|
||||
# Rooms
|
||||
# Top-left room
|
||||
|
|
@ -54,33 +76,42 @@ walls = [
|
|||
[(28, y) for y in range(15, 18)] + [(x, 18) for x in range(22, 28)],
|
||||
]
|
||||
|
||||
wall_cells = set()
|
||||
for wall_group in walls:
|
||||
for x, y in wall_group:
|
||||
if 0 <= x < 30 and 0 <= y < 20:
|
||||
if 0 <= x < GRID_W and 0 <= y < GRID_H:
|
||||
cell = grid.at(x, y)
|
||||
cell.walkable = False
|
||||
cell.transparent = False
|
||||
color_layer.set(x, y, mcrfpy.Color(40, 20, 20)) # Wall color
|
||||
color_layer.set((x, y), mcrfpy.Color(40, 20, 20)) # Wall color
|
||||
wall_cells.add((x, y))
|
||||
|
||||
# Create entities
|
||||
player = mcrfpy.Entity((5, 10), grid=grid)
|
||||
player = mcrfpy.Entity(grid_pos=(5, 10))
|
||||
player.sprite_index = 64 # @
|
||||
enemy = mcrfpy.Entity((25, 10), grid=grid)
|
||||
grid.grid_data.entities.append(player)
|
||||
enemy = mcrfpy.Entity(grid_pos=(25, 10))
|
||||
enemy.sprite_index = 69 # E
|
||||
grid.grid_data.entities.append(enemy)
|
||||
|
||||
# Short FOV radius: the default (10) is wide enough that the player's start cell
|
||||
# stays in FOV from the far side of the room, so cells could never demote from
|
||||
# VISIBLE to DISCOVERED. update_visibility() reads GridData.fov_radius (Entity.
|
||||
# sight_radius only feeds the TARGET trigger).
|
||||
grid.grid_data.fov_radius = 4
|
||||
|
||||
# Update initial visibility
|
||||
player.update_visibility()
|
||||
enemy.update_visibility()
|
||||
|
||||
# Global state
|
||||
current_perspective = -1
|
||||
# Global state: perspective is now an Entity | None, not an int index
|
||||
perspectives = [None, player, enemy]
|
||||
perspective_names = ["Omniscient", "Player", "Enemy"]
|
||||
current_perspective = 0
|
||||
|
||||
# UI Setup
|
||||
ui = visibility_demo.children
|
||||
ui.append(grid)
|
||||
grid.pos = (50, 100)
|
||||
grid.size = (900, 600) # 30*30, 20*30
|
||||
|
||||
# Title
|
||||
title = mcrfpy.Caption(pos=(350, 20), text="Interactive Visibility Demo")
|
||||
|
|
@ -92,10 +123,6 @@ perspective_label = mcrfpy.Caption(pos=(50, 50), text="Perspective: Omniscient")
|
|||
perspective_label.fill_color = mcrfpy.Color(200, 200, 200)
|
||||
ui.append(perspective_label)
|
||||
|
||||
controls = mcrfpy.Caption(pos=(50, 730), text="WASD: Move player | Arrows: Move enemy | Tab: Cycle perspective | Space: Update visibility | R: Reset")
|
||||
controls.fill_color = mcrfpy.Color(150, 150, 150)
|
||||
ui.append(controls)
|
||||
|
||||
player_info = mcrfpy.Caption(pos=(700, 50), text="Player: (5, 10)")
|
||||
player_info.fill_color = mcrfpy.Color(100, 255, 100)
|
||||
ui.append(player_info)
|
||||
|
|
@ -104,41 +131,41 @@ enemy_info = mcrfpy.Caption(pos=(700, 70), text="Enemy: (25, 10)")
|
|||
enemy_info.fill_color = mcrfpy.Color(255, 100, 100)
|
||||
ui.append(enemy_info)
|
||||
|
||||
|
||||
# Helper functions
|
||||
def move_entity(entity, dx, dy):
|
||||
"""Move entity if target is walkable"""
|
||||
new_x = int(entity.x + dx)
|
||||
new_y = int(entity.y + dy)
|
||||
|
||||
if 0 <= new_x < 30 and 0 <= new_y < 20:
|
||||
new_x = int(entity.grid_x + dx)
|
||||
new_y = int(entity.grid_y + dy)
|
||||
|
||||
if 0 <= new_x < GRID_W and 0 <= new_y < GRID_H:
|
||||
cell = grid.at(new_x, new_y)
|
||||
if cell.walkable:
|
||||
entity.x = new_x
|
||||
entity.y = new_y
|
||||
entity.grid_pos = (new_x, new_y)
|
||||
entity.update_visibility()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def update_info():
|
||||
"""Update info displays"""
|
||||
player_info.text = f"Player: ({int(player.x)}, {int(player.y)})"
|
||||
enemy_info.text = f"Enemy: ({int(enemy.x)}, {int(enemy.y)})"
|
||||
player_info.text = f"Player: ({player.grid_x}, {player.grid_y})"
|
||||
enemy_info.text = f"Enemy: ({enemy.grid_x}, {enemy.grid_y})"
|
||||
|
||||
|
||||
def cycle_perspective():
|
||||
"""Cycle through perspectives"""
|
||||
"""Cycle through perspectives: Omniscient -> Player -> Enemy -> Omniscient"""
|
||||
global current_perspective
|
||||
|
||||
# Cycle: -1 → 0 → 1 → -1
|
||||
current_perspective = (current_perspective + 2) % 3 - 1
|
||||
|
||||
grid.perspective = current_perspective
|
||||
name = perspective_names[current_perspective + 1]
|
||||
perspective_label.text = f"Perspective: {name}"
|
||||
current_perspective = (current_perspective + 1) % 3
|
||||
grid.perspective = perspectives[current_perspective]
|
||||
perspective_label.text = f"Perspective: {perspective_names[current_perspective]}"
|
||||
|
||||
|
||||
# Key handlers
|
||||
def handle_keys(key, state):
|
||||
"""Handle keyboard input"""
|
||||
if state == mcrfpy.InputState.RELEASED: return
|
||||
if state == mcrfpy.InputState.RELEASED:
|
||||
return
|
||||
# Player movement (WASD)
|
||||
if key == mcrfpy.Key.W:
|
||||
move_entity(player, 0, -1)
|
||||
|
|
@ -167,37 +194,120 @@ def handle_keys(key, state):
|
|||
elif key == mcrfpy.Key.SPACE:
|
||||
player.update_visibility()
|
||||
enemy.update_visibility()
|
||||
print("Updated visibility for both entities")
|
||||
|
||||
# R to reset
|
||||
elif key == mcrfpy.Key.R:
|
||||
player.x, player.y = 5, 10
|
||||
enemy.x, enemy.y = 25, 10
|
||||
player.grid_pos = (5, 10)
|
||||
enemy.grid_pos = (25, 10)
|
||||
player.update_visibility()
|
||||
enemy.update_visibility()
|
||||
update_info()
|
||||
print("Reset positions")
|
||||
|
||||
# Q to quit
|
||||
elif key == mcrfpy.Key.Q:
|
||||
print("Exiting...")
|
||||
sys.exit(0)
|
||||
|
||||
update_info()
|
||||
|
||||
# Set scene first
|
||||
visibility_demo.activate()
|
||||
|
||||
# Register key handler (operates on current scene)
|
||||
# Set scene first, then register key handler
|
||||
visibility_demo.activate()
|
||||
visibility_demo.on_key = handle_keys
|
||||
|
||||
print("Interactive Visibility Demo")
|
||||
print("===========================")
|
||||
print("WASD: Move player (green @)")
|
||||
print("Arrows: Move enemy (red E)")
|
||||
print("Tab: Cycle perspective")
|
||||
print("Space: Update visibility")
|
||||
print("R: Reset positions")
|
||||
print("Q: Quit")
|
||||
print("\nCurrent perspective: Omniscient (shows all)")
|
||||
print("Try moving entities and switching perspectives!")
|
||||
|
||||
def press(key):
|
||||
"""Deliver a real key event to the scene handler."""
|
||||
automation.keyDown(key)
|
||||
automation.keyUp(key)
|
||||
|
||||
|
||||
UNKNOWN = mcrfpy.Perspective.UNKNOWN
|
||||
DISCOVERED = mcrfpy.Perspective.DISCOVERED
|
||||
VISIBLE = mcrfpy.Perspective.VISIBLE
|
||||
|
||||
|
||||
def seen(entity, x, y):
|
||||
return entity.perspective_map.get(x, y)
|
||||
|
||||
|
||||
def main():
|
||||
# --- Initial visibility: player sees its own cell, and cannot see through
|
||||
# the solid wall at x=15 into the enemy's half of the map.
|
||||
check(seen(player, 5, 10) == VISIBLE, "player should see its own cell")
|
||||
check(seen(player, 25, 10) == UNKNOWN,
|
||||
f"player should not see across the wall; got {seen(player, 25, 10)}")
|
||||
check(seen(enemy, 25, 10) == VISIBLE, "enemy should see its own cell")
|
||||
check(seen(enemy, 5, 10) == UNKNOWN,
|
||||
f"enemy should not see across the wall; got {seen(enemy, 5, 10)}")
|
||||
check(player not in enemy.visible_entities(),
|
||||
"wall should hide the player from the enemy")
|
||||
|
||||
# --- WASD moves the player; cells it leaves behind stay DISCOVERED.
|
||||
press('d')
|
||||
check((player.grid_x, player.grid_y) == (6, 10),
|
||||
f"'d' should move player right; got {(player.grid_x, player.grid_y)}")
|
||||
press('s')
|
||||
check((player.grid_x, player.grid_y) == (6, 11),
|
||||
f"'s' should move player down; got {(player.grid_x, player.grid_y)}")
|
||||
check(player_info.text == "Player: (6, 11)", f"caption not updated: {player_info.text}")
|
||||
|
||||
# --- Walls block movement: walk the player into the central wall.
|
||||
while player.grid_x < 14:
|
||||
before = player.grid_x
|
||||
press('d')
|
||||
check(player.grid_x == before + 1, "player should walk freely up to the wall")
|
||||
check((player.grid_x, player.grid_y) == (14, 11), "player should be adjacent to the wall")
|
||||
press('d')
|
||||
check((player.grid_x, player.grid_y) == (14, 11),
|
||||
f"wall must block movement; player reached {(player.grid_x, player.grid_y)}")
|
||||
|
||||
# --- Having walked across the room, previously-seen cells are remembered
|
||||
# but no longer visible.
|
||||
check(seen(player, 5, 10) == DISCOVERED,
|
||||
f"start cell should be remembered, not visible; got {seen(player, 5, 10)}")
|
||||
check(seen(player, 14, 11) == VISIBLE, "player's current cell should be visible")
|
||||
check(seen(player, 25, 10) == UNKNOWN, "the wall still hides the enemy's half")
|
||||
|
||||
# --- Arrow keys move the enemy.
|
||||
press('left')
|
||||
check((enemy.grid_x, enemy.grid_y) == (24, 10),
|
||||
f"left arrow should move enemy; got {(enemy.grid_x, enemy.grid_y)}")
|
||||
press('up')
|
||||
check((enemy.grid_x, enemy.grid_y) == (24, 9),
|
||||
f"up arrow should move enemy; got {(enemy.grid_x, enemy.grid_y)}")
|
||||
|
||||
# --- Space recomputes visibility without moving anything.
|
||||
pos_before = (player.grid_x, player.grid_y, enemy.grid_x, enemy.grid_y)
|
||||
press('space')
|
||||
check((player.grid_x, player.grid_y, enemy.grid_x, enemy.grid_y) == pos_before,
|
||||
"space must not move entities")
|
||||
check(seen(player, 14, 11) == VISIBLE, "player still sees its own cell after space")
|
||||
|
||||
# --- Tab cycles perspective: Omniscient -> Player -> Enemy -> Omniscient.
|
||||
check(grid.perspective is None, "grid starts omniscient")
|
||||
press('tab')
|
||||
check(grid.perspective is player, f"tab 1 -> player; got {grid.perspective}")
|
||||
check(perspective_label.text == "Perspective: Player", perspective_label.text)
|
||||
press('tab')
|
||||
check(grid.perspective is enemy, f"tab 2 -> enemy; got {grid.perspective}")
|
||||
press('tab')
|
||||
check(grid.perspective is None, f"tab 3 -> omniscient; got {grid.perspective}")
|
||||
check(perspective_label.text == "Perspective: Omniscient", perspective_label.text)
|
||||
|
||||
# --- R resets positions and visibility.
|
||||
press('r')
|
||||
check((player.grid_x, player.grid_y) == (5, 10), "R resets the player")
|
||||
check((enemy.grid_x, enemy.grid_y) == (25, 10), "R resets the enemy")
|
||||
check(seen(player, 5, 10) == VISIBLE, "player sees its cell again after reset")
|
||||
check(seen(player, 14, 11) == DISCOVERED,
|
||||
f"cells left behind stay remembered after reset; got {seen(player, 14, 11)}")
|
||||
|
||||
# --- Perspective rendering path must not crash.
|
||||
grid.perspective = player
|
||||
automation.screenshot("interactive_visibility.png")
|
||||
grid.perspective = None
|
||||
|
||||
if failures:
|
||||
print(f"FAIL: {len(failures)} check(s) failed")
|
||||
sys.exit(1)
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -4,15 +4,28 @@
|
|||
import mcrfpy
|
||||
import sys
|
||||
|
||||
failures = []
|
||||
|
||||
def check(cond, msg):
|
||||
if cond:
|
||||
print(f" ok: {msg}")
|
||||
else:
|
||||
print(f" FAIL: {msg}")
|
||||
failures.append(msg)
|
||||
|
||||
# Create scene and grid
|
||||
print("Creating scene...")
|
||||
vis_test = mcrfpy.Scene("vis_test")
|
||||
|
||||
print("Creating grid...")
|
||||
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(50, 50), size=(300, 300))
|
||||
|
||||
# Add color layer for cell coloring
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
# (GridPoint has no .color anymore; per-cell color lives in a ColorLayer)
|
||||
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||
grid.add_layer(color_layer)
|
||||
check(grid.layer("color") is not None, "color layer attached to grid")
|
||||
check(tuple(color_layer.grid_size) == (10, 10), "color layer auto-resized to grid")
|
||||
|
||||
# Initialize grid
|
||||
print("Initializing grid...")
|
||||
|
|
@ -21,29 +34,61 @@ for y in range(10):
|
|||
cell = grid.at(x, y)
|
||||
cell.walkable = True
|
||||
cell.transparent = True
|
||||
color_layer.set(x, y, mcrfpy.Color(100, 100, 120))
|
||||
color_layer.set((x, y), mcrfpy.Color(100, 100, 120))
|
||||
|
||||
_c = color_layer.at(3, 7)
|
||||
check((_c.r, _c.g, _c.b) == (100, 100, 120), "color layer stores per-cell color")
|
||||
|
||||
# An opaque wall so visibility has something to actually occlude
|
||||
for y in range(10):
|
||||
wall = grid.at(2, y)
|
||||
wall.transparent = False
|
||||
wall.walkable = False
|
||||
|
||||
# Create entity
|
||||
print("Creating entity...")
|
||||
entity = mcrfpy.Entity((5, 5), grid=grid)
|
||||
entity = mcrfpy.Entity(grid_pos=(5, 5))
|
||||
entity.sprite_index = 64
|
||||
grid.entities.append(entity)
|
||||
check(entity.grid is grid.grid_data, "entity.grid is the shared GridData (#313/#361)")
|
||||
|
||||
print("Updating visibility...")
|
||||
entity.update_visibility()
|
||||
|
||||
# entity.gridstate -> entity.perspective_map (UNKNOWN/DISCOVERED/VISIBLE)
|
||||
pmap = entity.perspective_map
|
||||
check(pmap.get((5, 5)) == mcrfpy.Perspective.VISIBLE, "entity's own cell is VISIBLE")
|
||||
check(pmap.get((9, 5)) == mcrfpy.Perspective.VISIBLE, "open cell on entity's side is VISIBLE")
|
||||
check(pmap.get((0, 5)) == mcrfpy.Perspective.UNKNOWN, "cell behind the wall is UNKNOWN")
|
||||
check(grid.is_in_fov((5, 5)), "grid.is_in_fov agrees the entity's cell is lit")
|
||||
|
||||
# Set up UI
|
||||
print("Setting up UI...")
|
||||
ui = vis_test.children
|
||||
ui.append(grid)
|
||||
grid.pos = (50, 50)
|
||||
grid.size = (300, 300)
|
||||
check(len(ui) == 1, "grid appended to scene UI")
|
||||
|
||||
# Test perspective
|
||||
# perspective is now an Entity (fog-of-war source) or None (omniscient),
|
||||
# not the old integer entity index (-1 == omniscient).
|
||||
print("Testing perspective...")
|
||||
grid.perspective = -1 # Omniscient
|
||||
grid.perspective = entity
|
||||
check(grid.perspective is entity, "perspective bound to entity")
|
||||
check(grid.perspective_enabled, "perspective mode enabled")
|
||||
|
||||
grid.perspective = None # Omniscient
|
||||
print(f"Perspective set to: {grid.perspective}")
|
||||
check(grid.perspective is None, "perspective cleared")
|
||||
check(not grid.perspective_enabled, "omniscient: perspective mode disabled")
|
||||
|
||||
print("Setting scene...")
|
||||
vis_test.activate()
|
||||
check(mcrfpy.current_scene is vis_test, "scene activated")
|
||||
|
||||
print("Ready!")
|
||||
print("Ready!")
|
||||
|
||||
if failures:
|
||||
print(f"FAIL ({len(failures)} checks failed)")
|
||||
sys.exit(1)
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,39 +1,87 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Simple visibility test without entity append"""
|
||||
"""Simple visibility test without entity append
|
||||
|
||||
Original intent: verify an Entity associated with a Grid has per-entity visibility
|
||||
memory that is initialized, that entity.at(x, y) reports per-cell visibility state,
|
||||
and that entity.update_visibility() recomputes it.
|
||||
|
||||
API updates (current contract):
|
||||
- entity.gridstate -> entity.perspective_map (a 3-state DiscreteMap:
|
||||
UNKNOWN / DISCOVERED / VISIBLE, lazily allocated once the entity has a grid)
|
||||
- the per-cell object no longer carries .visible / .discovered; entity.at(x, y)
|
||||
returns a GridPoint only when the cell is currently visible, else None.
|
||||
Discovered-but-not-visible cells are read from perspective_map directly.
|
||||
- Entity(grid_pos=...) + grid.entities.append(entity) replaces Entity((x,y), grid=grid)
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
print("Simple visibility test...")
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(label, condition):
|
||||
if condition:
|
||||
print(f" PASS: {label}")
|
||||
else:
|
||||
print(f" FAIL: {label}")
|
||||
failures.append(label)
|
||||
|
||||
|
||||
# Create scene and grid
|
||||
simple = mcrfpy.Scene("simple")
|
||||
print("Scene created")
|
||||
|
||||
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
|
||||
grid = mcrfpy.Grid(grid_size=(5, 5))
|
||||
print("Grid created")
|
||||
|
||||
# Column x == 1 is an opaque wall; everything else is open floor.
|
||||
for x in range(5):
|
||||
for y in range(5):
|
||||
point = grid.at(x, y)
|
||||
point.walkable = True
|
||||
point.transparent = (x != 1)
|
||||
|
||||
# Create entity with grid association
|
||||
entity = mcrfpy.Entity((2, 2), grid=grid)
|
||||
print(f"Entity created at ({entity.x}, {entity.y})")
|
||||
entity = mcrfpy.Entity(grid_pos=(3, 2))
|
||||
grid.entities.append(entity)
|
||||
print(f"Entity created at {entity.cell_pos}")
|
||||
|
||||
# Check if gridstate is initialized
|
||||
print(f"Gridstate length: {len(entity.gridstate)}")
|
||||
# Check that per-entity visibility memory is initialized
|
||||
state_map = entity.perspective_map
|
||||
print(f"Perspective map size: {state_map.size}")
|
||||
check("perspective_map allocated", state_map is not None)
|
||||
check("perspective_map matches grid size", tuple(state_map.size) == (5, 5))
|
||||
check("perspective_map starts all UNKNOWN", state_map.count(mcrfpy.Perspective.UNKNOWN) == 25)
|
||||
|
||||
# Try to access at method
|
||||
try:
|
||||
state = entity.at(0, 0)
|
||||
print(f"at(0,0) returned: {state}")
|
||||
print(f"visible: {state.visible}, discovered: {state.discovered}")
|
||||
except Exception as e:
|
||||
print(f"Error in at(): {e}")
|
||||
# at() before any FOV computation: nothing is visible yet
|
||||
check("at(0, 0) is None before update_visibility", entity.at(0, 0) is None)
|
||||
|
||||
# Try update_visibility
|
||||
try:
|
||||
entity.update_visibility()
|
||||
print("update_visibility() succeeded")
|
||||
except Exception as e:
|
||||
print(f"Error in update_visibility(): {e}")
|
||||
# Compute visibility from (3, 2)
|
||||
entity.update_visibility()
|
||||
print("update_visibility() succeeded")
|
||||
|
||||
check("own cell is VISIBLE", state_map[3, 2] == mcrfpy.Perspective.VISIBLE)
|
||||
check("at(3, 2) returns the visible GridPoint", entity.at(3, 2) is not None)
|
||||
check("cell behind the wall stays UNKNOWN", state_map[0, 2] == mcrfpy.Perspective.UNKNOWN)
|
||||
check("at(0, 2) is None (occluded)", entity.at(0, 2) is None)
|
||||
|
||||
# Move across the wall: previously seen cells must be remembered as DISCOVERED,
|
||||
# not still reported as visible.
|
||||
entity.grid_pos = (0, 2)
|
||||
entity.update_visibility()
|
||||
print(f"Entity moved to {entity.cell_pos}")
|
||||
|
||||
check("new cell is VISIBLE", state_map[0, 2] == mcrfpy.Perspective.VISIBLE)
|
||||
check("old cell demoted to DISCOVERED", state_map[3, 2] == mcrfpy.Perspective.DISCOVERED)
|
||||
check("at(3, 2) is None once occluded again", entity.at(3, 2) is None)
|
||||
|
||||
if failures:
|
||||
print(f"Test complete - {len(failures)} FAILURE(S): {failures}")
|
||||
sys.exit(1)
|
||||
|
||||
print("Test complete")
|
||||
sys.exit(0)
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,107 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Trace interactive mode by monkey-patching"""
|
||||
"""Tripwire test: the engine must never consult the REPL prompt while running.
|
||||
|
||||
Originally this was a debugging aid -- it monkey-patched sys.ps1 with an object
|
||||
that printed a stack trace when accessed, then did "nothing else, let the game
|
||||
run", so a human could see whether the engine dropped into an interactive REPL.
|
||||
It never asserted anything and never declared an exit status.
|
||||
|
||||
The tripwire is still the right instrument; it just needs to be checked. sys.ps1
|
||||
is only read by a read-eval-print loop, so any access to it during an --exec run
|
||||
means the embedded interpreter fell into interactive mode (which, headless, hangs
|
||||
the process on stdin). This installs the detector, drives a full headless run
|
||||
past it (#350: mcrfpy.step() is the clock; screenshot() forces a render), and
|
||||
fails if the tripwire was touched.
|
||||
|
||||
Sibling force_non_interactive.py asserts the *static* non-interactive properties
|
||||
(flags, absent prompts, stdin); this one watches the *running* engine.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import traceback
|
||||
import mcrfpy
|
||||
|
||||
# Monkey-patch to detect interactive mode
|
||||
original_ps1 = None
|
||||
if hasattr(sys, 'ps1'):
|
||||
original_ps1 = sys.ps1
|
||||
|
||||
failures = []
|
||||
accesses = []
|
||||
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
if condition:
|
||||
print(" ok : %s" % label)
|
||||
else:
|
||||
print(" FAIL : %s %s" % (label, detail))
|
||||
failures.append(label)
|
||||
|
||||
|
||||
class PS1Detector:
|
||||
def __repr__(self):
|
||||
import traceback
|
||||
print("\n!!! sys.ps1 accessed! Stack trace:")
|
||||
"""Screams (and records) if anything asks for the interactive prompt."""
|
||||
|
||||
def _trip(self, how):
|
||||
accesses.append(how)
|
||||
print("\n!!! sys.ps1 accessed via %s! Stack trace:" % how)
|
||||
traceback.print_stack()
|
||||
return ">>> "
|
||||
|
||||
# Set our detector
|
||||
sys.ps1 = PS1Detector()
|
||||
|
||||
print("Trace script loaded, ps1 detector installed")
|
||||
def __repr__(self):
|
||||
return self._trip("__repr__")
|
||||
|
||||
# Do nothing else - let the game run
|
||||
def __str__(self):
|
||||
return self._trip("__str__")
|
||||
|
||||
|
||||
# The engine must not have installed a prompt of its own before we get here.
|
||||
check("sys.ps1 not preinstalled by engine", not hasattr(sys, "ps1"))
|
||||
|
||||
detector = PS1Detector()
|
||||
sys.ps1 = detector
|
||||
print("ps1 detector installed")
|
||||
|
||||
# Now let the engine actually run. In headless mode the engine advances only when
|
||||
# we drive it, so "letting the game run" means stepping the clock ourselves and
|
||||
# forcing a render -- both of which are the paths that historically could reenter
|
||||
# the interpreter.
|
||||
scene = mcrfpy.Scene("trace_interactive")
|
||||
mcrfpy.current_scene = scene
|
||||
|
||||
ticks = []
|
||||
mcrfpy.Timer("tick", lambda timer, runtime: ticks.append(runtime), 50)
|
||||
|
||||
frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100))
|
||||
scene.children.append(frame)
|
||||
frame.animate("x", 200.0, 0.2, mcrfpy.Easing.EASE_IN_OUT)
|
||||
|
||||
for _ in range(10):
|
||||
mcrfpy.step(0.05)
|
||||
|
||||
shot = "trace_interactive_render.png"
|
||||
mcrfpy.automation.screenshot(shot)
|
||||
if os.path.exists(shot):
|
||||
os.remove(shot)
|
||||
|
||||
# The run must have been real, or the tripwire proves nothing.
|
||||
check("headless clock advanced (timer fired)", len(ticks) >= 1,
|
||||
"timer fired %d time(s)" % len(ticks))
|
||||
check("animation advanced during run", frame.x > 10.0,
|
||||
"frame.x = %r" % (frame.x,))
|
||||
|
||||
# The tripwire itself: nothing may have read sys.ps1 during any of that.
|
||||
check("sys.ps1 never accessed during engine run", not accesses,
|
||||
"accessed %d time(s): %s" % (len(accesses), accesses))
|
||||
|
||||
# And the engine must not have reconfigured the interpreter behind our back --
|
||||
# our detector should still be the installed prompt, and inspect mode still off.
|
||||
check("sys.ps1 still our detector", sys.ps1 is detector)
|
||||
check("sys.flags.inspect == 0", sys.flags.inspect == 0,
|
||||
"got %r" % (sys.flags.inspect,))
|
||||
check("sys.flags.interactive == 0", sys.flags.interactive == 0,
|
||||
"got %r" % (sys.flags.interactive,))
|
||||
|
||||
# Leave the interpreter as we found it.
|
||||
del sys.ps1
|
||||
|
||||
if failures:
|
||||
print("FAIL: %d check(s) failed: %s" % (len(failures), ", ".join(failures)))
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,44 +1,61 @@
|
|||
"""Regression test for Grid backward compatibility with GridView shim (#252)."""
|
||||
"""Regression test for Grid/GridView/GridData compatibility (#252, updated for #313/#361).
|
||||
|
||||
The old GridView "shim" model (Grid owns data, .view property auto-creates a separate
|
||||
GridView object) is gone. Current contract: mcrfpy.Grid IS mcrfpy.GridView (same type);
|
||||
the view owns rendering state (zoom/center/camera), and the shared map lives in
|
||||
.grid_data (a mcrfpy.GridData -- cells, entities, layers; not a drawable).
|
||||
|
||||
The intent of each test below is preserved; the assertions were retargeted at the
|
||||
current contract.
|
||||
"""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
def test_grid_creates_view():
|
||||
"""Grid auto-creates a GridView accessible via .view property."""
|
||||
def test_grid_creates_grid_data():
|
||||
"""Grid auto-creates its GridData, accessible via .grid_data."""
|
||||
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
|
||||
assert grid.view is not None, "Grid should auto-create a view"
|
||||
assert isinstance(grid.view, mcrfpy.GridView)
|
||||
print("PASS: Grid auto-creates view")
|
||||
assert grid.grid_data is not None, "Grid should auto-create its grid data"
|
||||
assert isinstance(grid.grid_data, mcrfpy.GridData)
|
||||
# Grid and GridView are the same type now (alias), not shim + shimmed object.
|
||||
assert mcrfpy.Grid is mcrfpy.GridView, "Grid should be an alias of GridView"
|
||||
assert isinstance(grid, mcrfpy.GridView)
|
||||
print("PASS: Grid auto-creates grid data")
|
||||
|
||||
def test_view_shares_grid_data():
|
||||
"""GridView created by shim shares the same grid data."""
|
||||
"""A GridView constructed over an existing Grid shares the same GridData."""
|
||||
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
|
||||
view = grid.view
|
||||
view = mcrfpy.GridView(grid=grid, pos=(0, 0), size=(160, 160))
|
||||
|
||||
# View's grid should be the same Grid
|
||||
assert view.grid is grid, "view.grid should be the same Grid"
|
||||
assert view.grid.grid_w == 10
|
||||
# The view must share -- not copy -- the source grid's data.
|
||||
assert view.grid_data is grid.grid_data, "view should share the Grid's GridData"
|
||||
assert view.grid_data.grid_w == 10
|
||||
# Passing the GridData itself is equivalent.
|
||||
view2 = mcrfpy.GridView(grid=grid.grid_data, pos=(0, 0), size=(160, 160))
|
||||
assert view2.grid_data is grid.grid_data, "GridView(grid=GridData) should share it"
|
||||
print("PASS: view shares grid data")
|
||||
|
||||
def test_rendering_property_sync():
|
||||
"""Setting rendering properties on Grid syncs to the view."""
|
||||
def test_rendering_properties():
|
||||
"""Rendering properties live on the view (Grid) itself and round-trip."""
|
||||
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
|
||||
view = grid.view
|
||||
|
||||
grid.zoom = 3.0
|
||||
assert abs(view.zoom - 3.0) < 0.01, f"View zoom should sync: {view.zoom}"
|
||||
assert abs(grid.zoom - 3.0) < 0.01, f"zoom should round-trip: {grid.zoom}"
|
||||
|
||||
grid.center_x = 200.0
|
||||
assert abs(view.center.x - 200.0) < 0.01, f"View center_x should sync: {view.center.x}"
|
||||
assert abs(grid.center.x - 200.0) < 0.01, f"center.x should track center_x: {grid.center.x}"
|
||||
|
||||
grid.center_y = 150.0
|
||||
assert abs(view.center.y - 150.0) < 0.01, f"View center_y should sync: {view.center.y}"
|
||||
assert abs(grid.center.y - 150.0) < 0.01, f"center.y should track center_y: {grid.center.y}"
|
||||
|
||||
grid.camera_rotation = 45.0
|
||||
# camera_rotation syncs through set_float_member
|
||||
print("PASS: rendering properties sync to view")
|
||||
assert abs(grid.camera_rotation - 45.0) < 0.01, f"camera_rotation should round-trip: {grid.camera_rotation}"
|
||||
|
||||
# Rendering state is view-only; it must not have leaked onto the shared map.
|
||||
assert not hasattr(grid.grid_data, "zoom"), "GridData must not carry rendering state"
|
||||
print("PASS: rendering properties live on the view")
|
||||
|
||||
def test_grid_still_works_in_scene():
|
||||
"""Grid can still be appended to scenes and works as before."""
|
||||
|
|
@ -52,7 +69,8 @@ def test_grid_still_works_in_scene():
|
|||
# Grid is in the scene as itself (not substituted)
|
||||
assert len(scene.children) == 1
|
||||
retrieved = scene.children[0]
|
||||
assert type(retrieved).__name__ == "Grid", f"Expected Grid, got {type(retrieved).__name__}"
|
||||
assert retrieved is grid, "scene.children should return the same object (#369)"
|
||||
assert type(retrieved).__name__ == "GridView", f"Expected GridView, got {type(retrieved).__name__}"
|
||||
print("PASS: Grid works in scene as before")
|
||||
|
||||
def test_grid_subclass_preserved():
|
||||
|
|
@ -80,47 +98,66 @@ def test_entity_operations_unaffected():
|
|||
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
|
||||
scene.children.append(grid)
|
||||
|
||||
data = grid.grid_data
|
||||
for y in range(20):
|
||||
for x in range(20):
|
||||
grid.at(x, y).walkable = True
|
||||
grid.at(x, y).transparent = True
|
||||
data.at(x, y).walkable = True
|
||||
data.at(x, y).transparent = True
|
||||
|
||||
e = mcrfpy.Entity((5, 5), grid=grid)
|
||||
assert len(grid.entities) == 1
|
||||
assert len(data.entities) == 1
|
||||
assert e.cell_x == 5
|
||||
assert e.cell_y == 5
|
||||
assert len(grid.at(5, 5).entities) == 1
|
||||
assert len(data.at(5, 5).entities) == 1
|
||||
# Current contract (#313/#361): entity.grid is the shared GridData, not the view.
|
||||
assert e.grid is data, "entity.grid should be the GridData it lives in"
|
||||
print("PASS: entity operations unaffected")
|
||||
|
||||
def test_gridview_independent_rendering():
|
||||
"""A GridView can have different rendering settings from the Grid."""
|
||||
"""A second GridView over the same data has independent rendering settings."""
|
||||
scene = mcrfpy.Scene("test_independent")
|
||||
mcrfpy.current_scene = scene
|
||||
|
||||
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
|
||||
|
||||
# Create an explicit GridView with different settings
|
||||
# Create an explicit second view over the same grid data, with different settings
|
||||
view2 = mcrfpy.GridView(grid=grid, pos=(350, 0), size=(160, 160), zoom=0.5)
|
||||
scene.children.append(grid)
|
||||
scene.children.append(view2)
|
||||
|
||||
assert view2.grid_data is grid.grid_data, "both views should share one GridData"
|
||||
|
||||
# Grid and view2 should have different zoom
|
||||
assert abs(grid.zoom - 1.0) < 0.01
|
||||
assert abs(view2.zoom - 0.5) < 0.01
|
||||
|
||||
# Changing grid zoom doesn't affect explicit GridView
|
||||
# Changing grid zoom doesn't affect the second view
|
||||
grid.zoom = 2.0
|
||||
assert abs(view2.zoom - 0.5) < 0.01, "Explicit GridView should keep its own zoom"
|
||||
assert abs(view2.zoom - 0.5) < 0.01, "Second GridView should keep its own zoom"
|
||||
assert abs(grid.zoom - 2.0) < 0.01
|
||||
print("PASS: GridView independent rendering")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_grid_creates_view()
|
||||
test_view_shares_grid_data()
|
||||
test_rendering_property_sync()
|
||||
test_grid_still_works_in_scene()
|
||||
test_grid_subclass_preserved()
|
||||
test_entity_operations_unaffected()
|
||||
test_gridview_independent_rendering()
|
||||
tests = [
|
||||
test_grid_creates_grid_data,
|
||||
test_view_shares_grid_data,
|
||||
test_rendering_properties,
|
||||
test_grid_still_works_in_scene,
|
||||
test_grid_subclass_preserved,
|
||||
test_entity_operations_unaffected,
|
||||
test_gridview_independent_rendering,
|
||||
]
|
||||
failures = 0
|
||||
for t in tests:
|
||||
try:
|
||||
t()
|
||||
except AssertionError as e:
|
||||
failures += 1
|
||||
print(f"FAIL: {t.__name__}: {e}")
|
||||
if failures:
|
||||
print(f"FAILED: {failures} of {len(tests)} tests failed")
|
||||
sys.exit(1)
|
||||
print("All backward compatibility tests passed")
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -12,9 +12,18 @@ while small grids use the original flat storage. Verifies that:
|
|||
NOTE: This test uses ColorLayer for color operations since cell.color
|
||||
is no longer supported. The chunk system affects internal storage, which
|
||||
ColorLayer also uses.
|
||||
|
||||
API notes (current contract):
|
||||
- add_layer() takes a layer OBJECT and no keyword arguments:
|
||||
grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
|
||||
- ColorLayer.set()/at() take a POSITION tuple, not separate x/y args to set().
|
||||
- GridPoint no longer has .tilesprite; cell read/write coverage is preserved
|
||||
by exercising .walkable (a real per-cell field that lives in the same
|
||||
chunked storage the issue is about).
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
import sys
|
||||
|
||||
def test_small_grid():
|
||||
|
|
@ -23,23 +32,28 @@ def test_small_grid():
|
|||
|
||||
# Small grid should use flat storage
|
||||
grid = mcrfpy.Grid(grid_size=(50, 50), pos=(10, 10), size=(400, 400))
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
|
||||
|
||||
# Set some cells
|
||||
for y in range(50):
|
||||
for x in range(50):
|
||||
cell = grid.at(x, y)
|
||||
color_layer.set(x, y, mcrfpy.Color((x * 5) % 256, (y * 5) % 256, 128, 255))
|
||||
cell.tilesprite = -1
|
||||
color_layer.set((x, y), mcrfpy.Color((x * 5) % 256, (y * 5) % 256, 128, 255))
|
||||
cell.walkable = ((x + y) % 2 == 0)
|
||||
|
||||
# Verify cells
|
||||
expected_r = (25 * 5) % 256
|
||||
expected_g = (25 * 5) % 256
|
||||
color = color_layer.at(25, 25)
|
||||
color = color_layer.at((25, 25))
|
||||
if color.r != expected_r or color.g != expected_g:
|
||||
print(f"FAIL: Small grid cell color mismatch. Expected ({expected_r}, {expected_g}), got ({color.r}, {color.g})")
|
||||
return False
|
||||
|
||||
# Verify per-cell (non-color) storage round-trips
|
||||
if not grid.at(24, 24).walkable or grid.at(25, 24).walkable:
|
||||
print("FAIL: Small grid cell walkable mismatch")
|
||||
return False
|
||||
|
||||
print(" Small grid: PASS")
|
||||
return True
|
||||
|
||||
|
|
@ -49,7 +63,7 @@ def test_large_grid():
|
|||
|
||||
# Large grid should use chunk storage (100 > 64)
|
||||
grid = mcrfpy.Grid(grid_size=(100, 100), pos=(10, 10), size=(400, 400))
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
|
||||
|
||||
# Set cells across multiple chunks
|
||||
# Chunks are 64x64, so a 100x100 grid has 2x2 = 4 chunks
|
||||
|
|
@ -65,15 +79,18 @@ def test_large_grid():
|
|||
|
||||
for x, y in test_points:
|
||||
cell = grid.at(x, y)
|
||||
color_layer.set(x, y, mcrfpy.Color(x, y, 100, 255))
|
||||
cell.tilesprite = -1
|
||||
color_layer.set((x, y), mcrfpy.Color(x, y, 100, 255))
|
||||
cell.walkable = False
|
||||
|
||||
# Verify cells
|
||||
for x, y in test_points:
|
||||
color = color_layer.at(x, y)
|
||||
color = color_layer.at((x, y))
|
||||
if color.r != x or color.g != y:
|
||||
print(f"FAIL: Large grid cell ({x},{y}) color mismatch. Expected ({x}, {y}), got ({color.r}, {color.g})")
|
||||
return False
|
||||
if grid.at(x, y).walkable:
|
||||
print(f"FAIL: Large grid cell ({x},{y}) walkable not persisted across chunk boundary")
|
||||
return False
|
||||
|
||||
print(" Large grid cell access: PASS")
|
||||
return True
|
||||
|
|
@ -84,7 +101,7 @@ def test_very_large_grid():
|
|||
|
||||
# 500x500 = 250,000 cells, should use ~64 chunks (8x8)
|
||||
grid = mcrfpy.Grid(grid_size=(500, 500), pos=(10, 10), size=(400, 400))
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
|
||||
|
||||
# Set some cells at various positions
|
||||
test_points = [
|
||||
|
|
@ -98,11 +115,11 @@ def test_very_large_grid():
|
|||
]
|
||||
|
||||
for x, y in test_points:
|
||||
color_layer.set(x, y, mcrfpy.Color(x % 256, y % 256, 200, 255))
|
||||
color_layer.set((x, y), mcrfpy.Color(x % 256, y % 256, 200, 255))
|
||||
|
||||
# Verify
|
||||
for x, y in test_points:
|
||||
color = color_layer.at(x, y)
|
||||
color = color_layer.at((x, y))
|
||||
if color.r != (x % 256) or color.g != (y % 256):
|
||||
print(f"FAIL: Very large grid cell ({x},{y}) color mismatch")
|
||||
return False
|
||||
|
|
@ -116,18 +133,18 @@ def test_boundary_case():
|
|||
|
||||
# 64x64 should use flat storage (not exceeding threshold)
|
||||
grid_64 = mcrfpy.Grid(grid_size=(64, 64), pos=(10, 10), size=(400, 400))
|
||||
color_layer_64 = grid_64.add_layer("color", z_index=-1)
|
||||
color_layer_64.set(63, 63, mcrfpy.Color(255, 0, 0, 255))
|
||||
color = color_layer_64.at(63, 63)
|
||||
color_layer_64 = grid_64.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
|
||||
color_layer_64.set((63, 63), mcrfpy.Color(255, 0, 0, 255))
|
||||
color = color_layer_64.at((63, 63))
|
||||
if color.r != 255:
|
||||
print(f"FAIL: 64x64 grid boundary cell not set correctly, got r={color.r}")
|
||||
return False
|
||||
|
||||
# 65x65 should use chunk storage (exceeding threshold)
|
||||
grid_65 = mcrfpy.Grid(grid_size=(65, 65), pos=(10, 10), size=(400, 400))
|
||||
color_layer_65 = grid_65.add_layer("color", z_index=-1)
|
||||
color_layer_65.set(64, 64, mcrfpy.Color(0, 255, 0, 255))
|
||||
color = color_layer_65.at(64, 64)
|
||||
color_layer_65 = grid_65.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
|
||||
color_layer_65.set((64, 64), mcrfpy.Color(0, 255, 0, 255))
|
||||
color = color_layer_65.at((64, 64))
|
||||
if color.g != 255:
|
||||
print(f"FAIL: 65x65 grid cell not set correctly, got g={color.g}")
|
||||
return False
|
||||
|
|
@ -141,16 +158,16 @@ def test_edge_cases():
|
|||
|
||||
# Create 100x100 grid
|
||||
grid = mcrfpy.Grid(grid_size=(100, 100), pos=(10, 10), size=(400, 400))
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
|
||||
|
||||
# Test all corners
|
||||
corners = [(0, 0), (99, 0), (0, 99), (99, 99)]
|
||||
for i, (x, y) in enumerate(corners):
|
||||
color_layer.set(x, y, mcrfpy.Color(i * 60, i * 60, i * 60, 255))
|
||||
color_layer.set((x, y), mcrfpy.Color(i * 60, i * 60, i * 60, 255))
|
||||
|
||||
for i, (x, y) in enumerate(corners):
|
||||
expected = i * 60
|
||||
color = color_layer.at(x, y)
|
||||
color = color_layer.at((x, y))
|
||||
if color.r != expected:
|
||||
print(f"FAIL: Corner ({x},{y}) color mismatch, expected {expected}, got {color.r}")
|
||||
return False
|
||||
|
|
@ -158,6 +175,31 @@ def test_edge_cases():
|
|||
print(" Edge cases: PASS")
|
||||
return True
|
||||
|
||||
def test_rendering(scene):
|
||||
"""Test that both storage modes actually render (flat + chunked on screen)"""
|
||||
print("Testing rendering of flat and chunked grids...")
|
||||
|
||||
small = mcrfpy.Grid(grid_size=(50, 50), pos=(10, 10), size=(200, 200))
|
||||
small_colors = small.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
|
||||
large = mcrfpy.Grid(grid_size=(100, 100), pos=(220, 10), size=(200, 200))
|
||||
large_colors = large.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
|
||||
|
||||
for i in range(50):
|
||||
small_colors.set((i, i), mcrfpy.Color(255, 0, 0, 255))
|
||||
for i in range(100):
|
||||
large_colors.set((i, i), mcrfpy.Color(0, 0, 255, 255))
|
||||
|
||||
scene.children.append(small)
|
||||
scene.children.append(large)
|
||||
|
||||
# Rendering is orthogonal to sim time; screenshot forces a render pass.
|
||||
if not automation.screenshot("issue_123_chunk_render.png"):
|
||||
print("FAIL: screenshot of flat + chunked grids failed")
|
||||
return False
|
||||
|
||||
print(" Rendering: PASS")
|
||||
return True
|
||||
|
||||
# Main
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
|
|
@ -173,9 +215,11 @@ if __name__ == "__main__":
|
|||
results.append(test_very_large_grid())
|
||||
results.append(test_boundary_case())
|
||||
results.append(test_edge_cases())
|
||||
results.append(test_rendering(test))
|
||||
|
||||
if all(results):
|
||||
print("\n=== ALL TESTS PASSED ===")
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\n=== SOME TESTS FAILED ===")
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ print("=" * 60)
|
|||
test = mcrfpy.Scene("test")
|
||||
mcrfpy.current_scene = test
|
||||
ui = test.children
|
||||
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
|
||||
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||
|
||||
grid = mcrfpy.Grid(pos=(0,0), size=(400,300), grid_size=(50, 50), texture=texture)
|
||||
ui.append(grid)
|
||||
|
|
@ -34,13 +34,15 @@ for y in range(50):
|
|||
cell.walkable = True
|
||||
cell.transparent = True
|
||||
|
||||
# Add some walls to test blocking
|
||||
for i in range(10, 20):
|
||||
grid.at(i, 25).transparent = False
|
||||
grid.at(i, 25).walkable = False
|
||||
# Add a wall that actually occludes: a vertical run at x=20, spanning the
|
||||
# rows around the viewer at (25,25). Cells at x < 20 on row 25 are then
|
||||
# within radius but geometrically behind the wall.
|
||||
for j in range(20, 31):
|
||||
grid.at(20, j).transparent = False
|
||||
grid.at(20, j).walkable = False
|
||||
|
||||
print("\n--- Test 1: compute_fov() returns None ---")
|
||||
result = grid.compute_fov(25, 25, radius=10)
|
||||
result = grid.compute_fov((25, 25), radius=10)
|
||||
if result is None:
|
||||
print(" PASS: compute_fov() returned None")
|
||||
else:
|
||||
|
|
@ -55,16 +57,16 @@ else:
|
|||
print(" FAIL: Center should be in FOV")
|
||||
sys.exit(1)
|
||||
|
||||
# Cell within radius should be visible
|
||||
if grid.is_in_fov(20, 25):
|
||||
print(" PASS: Cell (20,25) within radius is in FOV")
|
||||
# Cell within radius and in front of the wall should be visible
|
||||
if grid.is_in_fov(22, 25):
|
||||
print(" PASS: Cell (22,25) within radius is in FOV")
|
||||
else:
|
||||
print(" FAIL: Cell (20,25) should be in FOV")
|
||||
print(" FAIL: Cell (22,25) should be in FOV")
|
||||
sys.exit(1)
|
||||
|
||||
# Cell behind wall should NOT be visible
|
||||
if not grid.is_in_fov(15, 30):
|
||||
print(" PASS: Cell (15,30) behind wall is NOT in FOV")
|
||||
# Cell behind the wall (within radius) should NOT be visible
|
||||
if not grid.is_in_fov(18, 25):
|
||||
print(" PASS: Cell (18,25) behind wall is NOT in FOV")
|
||||
else:
|
||||
print(" FAIL: Cell behind wall should not be in FOV")
|
||||
sys.exit(1)
|
||||
|
|
@ -89,7 +91,7 @@ for y in range(0, 200, 5): # Sample for speed
|
|||
times = []
|
||||
for i in range(5):
|
||||
t0 = time.perf_counter()
|
||||
grid_large.compute_fov(100, 100, radius=15)
|
||||
grid_large.compute_fov((100, 100), radius=15)
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
times.append(elapsed)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@ Tests:
|
|||
2. TileLayer creation and manipulation
|
||||
3. Layer z_index ordering relative to entities
|
||||
4. Layer management (add_layer, remove_layer, layers property)
|
||||
|
||||
API notes (current contract):
|
||||
- Layers are constructed standalone (TileLayer/ColorLayer ctors take kwargs) and then
|
||||
attached with grid_data.add_layer(layer) -- add_layer takes an object, not kwargs.
|
||||
- Layer management (add_layer / remove_layer / layer) lives on GridData
|
||||
(grid.grid_data); the Grid view exposes the read-only `layers` tuple.
|
||||
- Layers are looked up BY NAME, not by z_index (grid_data.layer(name)).
|
||||
"""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
|
@ -19,21 +26,22 @@ print("=" * 60)
|
|||
test = mcrfpy.Scene("test")
|
||||
mcrfpy.current_scene = test
|
||||
ui = test.children
|
||||
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
|
||||
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||
|
||||
# Create grid with explicit empty layers (#150 migration)
|
||||
grid = mcrfpy.Grid(pos=(50, 50), size=(400, 300), grid_size=(20, 15), texture=texture, layers={})
|
||||
grid = mcrfpy.Grid(pos=(50, 50), size=(400, 300), grid_size=(20, 15), texture=texture, layers=[])
|
||||
ui.append(grid)
|
||||
grid_data = grid.grid_data
|
||||
|
||||
print("\n--- Test 1: Initial state (no layers) ---")
|
||||
if len(grid.layers) == 0:
|
||||
print(" PASS: Grid starts with no layers (layers={})")
|
||||
print(" PASS: Grid starts with no layers (layers=[])")
|
||||
else:
|
||||
print(f" FAIL: Expected 0 layers, got {len(grid.layers)}")
|
||||
sys.exit(1)
|
||||
|
||||
print("\n--- Test 2: Add ColorLayer ---")
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
color_layer = grid_data.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
|
||||
print(f" Created: {color_layer}")
|
||||
if color_layer is not None:
|
||||
print(" PASS: ColorLayer created")
|
||||
|
|
@ -63,7 +71,7 @@ else:
|
|||
|
||||
print("\n--- Test 3: ColorLayer cell access ---")
|
||||
# Set a color
|
||||
color_layer.set(5, 5, mcrfpy.Color(255, 0, 0, 128))
|
||||
color_layer.set((5, 5), mcrfpy.Color(255, 0, 0, 128))
|
||||
color = color_layer.at(5, 5)
|
||||
if color.r == 255 and color.g == 0 and color.b == 0 and color.a == 128:
|
||||
print(f" PASS: Color at (5,5) is {color.r}, {color.g}, {color.b}, {color.a}")
|
||||
|
|
@ -81,7 +89,7 @@ else:
|
|||
sys.exit(1)
|
||||
|
||||
print("\n--- Test 4: Add TileLayer ---")
|
||||
tile_layer = grid.add_layer("tile", z_index=-2, texture=texture)
|
||||
tile_layer = grid_data.add_layer(mcrfpy.TileLayer(name="tile", z_index=-2, texture=texture))
|
||||
print(f" Created: {tile_layer}")
|
||||
if tile_layer is not None:
|
||||
print(" PASS: TileLayer created")
|
||||
|
|
@ -97,7 +105,7 @@ else:
|
|||
|
||||
print("\n--- Test 5: TileLayer cell access ---")
|
||||
# Set a tile
|
||||
tile_layer.set(3, 3, 42)
|
||||
tile_layer.set((3, 3), 42)
|
||||
tile = tile_layer.at(3, 3)
|
||||
if tile == 42:
|
||||
print(f" PASS: Tile at (3,3) is {tile}")
|
||||
|
|
@ -129,30 +137,34 @@ else:
|
|||
print(f" FAIL: Layers not sorted")
|
||||
sys.exit(1)
|
||||
|
||||
print("\n--- Test 7: Get layer by z_index ---")
|
||||
layer = grid.layer(-1)
|
||||
if layer is not None and layer.z_index == -1:
|
||||
print(" PASS: grid.layer(-1) returns ColorLayer")
|
||||
print("\n--- Test 7: Get layer by name ---")
|
||||
# Layer lookup is by NAME now (grid.layer(z_index) no longer exists); z_index is still
|
||||
# the ordering key, verified in Test 6. Note: layer lookups return a fresh wrapper each
|
||||
# call (layers are not in PythonObjectCache), so compare identity via the shared data,
|
||||
# not `is`.
|
||||
layer = grid_data.layer("color")
|
||||
if layer is not None and layer.z_index == -1 and layer.at(5, 5).b == color_layer.at(5, 5).b:
|
||||
print(" PASS: grid_data.layer('color') returns the ColorLayer")
|
||||
else:
|
||||
print(" FAIL: Could not get layer by z_index")
|
||||
print(" FAIL: Could not get ColorLayer by name")
|
||||
sys.exit(1)
|
||||
|
||||
layer = grid.layer(-2)
|
||||
if layer is not None and layer.z_index == -2:
|
||||
print(" PASS: grid.layer(-2) returns TileLayer")
|
||||
layer = grid_data.layer("tile")
|
||||
if layer is not None and layer.z_index == -2 and layer.at(3, 3) == tile_layer.at(3, 3):
|
||||
print(" PASS: grid_data.layer('tile') returns the TileLayer")
|
||||
else:
|
||||
print(" FAIL: Could not get layer by z_index")
|
||||
print(" FAIL: Could not get TileLayer by name")
|
||||
sys.exit(1)
|
||||
|
||||
layer = grid.layer(999)
|
||||
layer = grid_data.layer("nonexistent")
|
||||
if layer is None:
|
||||
print(" PASS: grid.layer(999) returns None for non-existent layer")
|
||||
print(" PASS: grid_data.layer('nonexistent') returns None for non-existent layer")
|
||||
else:
|
||||
print(" FAIL: Should return None for non-existent layer")
|
||||
sys.exit(1)
|
||||
|
||||
print("\n--- Test 8: Layer above entities (z_index >= 0) ---")
|
||||
fog_layer = grid.add_layer("color", z_index=1)
|
||||
fog_layer = grid_data.add_layer(mcrfpy.ColorLayer(name="fog", z_index=1))
|
||||
if fog_layer.z_index == 1:
|
||||
print(" PASS: Created layer with z_index=1 (above entities)")
|
||||
else:
|
||||
|
|
@ -165,7 +177,7 @@ print(" PASS: Fog layer filled")
|
|||
|
||||
print("\n--- Test 9: Remove layer ---")
|
||||
initial_count = len(grid.layers)
|
||||
grid.remove_layer(fog_layer)
|
||||
grid_data.remove_layer(fog_layer)
|
||||
final_count = len(grid.layers)
|
||||
if final_count == initial_count - 1:
|
||||
print(f" PASS: Layer removed ({initial_count} -> {final_count})")
|
||||
|
|
|
|||
|
|
@ -7,12 +7,64 @@ Tests:
|
|||
2. Setting cell values marks layer dirty
|
||||
3. Fill operation marks layer dirty
|
||||
4. Texture change marks TileLayer dirty
|
||||
5. Viewport changes (center/zoom) don't trigger re-render (static benchmark)
|
||||
6. Performance improvement for static layers
|
||||
5. Viewport changes (center/zoom) don't corrupt the cached texture
|
||||
6. Performance / large-layer handling for static layers
|
||||
7. Layer visibility toggle marks the layer dirty
|
||||
8. Large grid stress test
|
||||
|
||||
REPAIR NOTE (API drift):
|
||||
* add_layer() no longer takes kwargs and no longer constructs the layer for you.
|
||||
Construct a TileLayer/ColorLayer and attach it: grid.add_layer(layer).
|
||||
* layer.set() takes a position TUPLE: layer.set((x, y), value) -- not set(x, y, value).
|
||||
* assets/kenney_ice.png does not exist; using kenney_tinydungeon.png.
|
||||
* step() is the clock and NEVER renders (#350); rendering is forced with
|
||||
automation.screenshot(), which costs zero simulation time.
|
||||
|
||||
REPAIR NOTE (real coverage):
|
||||
The original file punted on the actual subject of #148 -- it said "dirty flag
|
||||
behavior is internal" and only checked that the API didn't crash, so it proved
|
||||
nothing about caching. Dirty-flag behavior IS observable from Python now: the
|
||||
GridView caches its composed output and only re-renders when a layer's markDirty()
|
||||
bumps the grid's content_generation (#351). So a mutation that fails to set the
|
||||
dirty flag produces a byte-identical screenshot -- a stale cache. We render to PNG
|
||||
and compare hashes to assert invalidation actually happens.
|
||||
"""
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
import sys
|
||||
import time
|
||||
import os
|
||||
import hashlib
|
||||
import tempfile
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
if condition:
|
||||
print(f" PASS: {label}")
|
||||
else:
|
||||
print(f" FAIL: {label} {detail}")
|
||||
failures.append(label)
|
||||
|
||||
|
||||
_shot_n = [0]
|
||||
|
||||
|
||||
def render_hash():
|
||||
"""Force a render (screenshot) and hash the pixels.
|
||||
|
||||
step() never renders, so this is the only way to observe what the grid's cached
|
||||
RenderTexture actually contains. Identical bytes across a mutation == stale cache
|
||||
== the dirty flag was not set.
|
||||
"""
|
||||
_shot_n[0] += 1
|
||||
path = os.path.join(tempfile.gettempdir(), f"issue148_{_shot_n[0]}.png")
|
||||
automation.screenshot(path)
|
||||
with open(path, "rb") as f:
|
||||
h = hashlib.sha256(f.read()).hexdigest()
|
||||
os.remove(path)
|
||||
return h
|
||||
|
||||
|
||||
print("=" * 60)
|
||||
print("Issue #148 Regression Test: Layer Dirty Flags and Caching")
|
||||
|
|
@ -22,126 +74,186 @@ print("=" * 60)
|
|||
test = mcrfpy.Scene("test")
|
||||
mcrfpy.current_scene = test
|
||||
ui = test.children
|
||||
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
|
||||
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||
|
||||
# Create grid with larger size for performance testing
|
||||
grid = mcrfpy.Grid(pos=(50, 50), size=(500, 400), grid_size=(50, 40), texture=texture)
|
||||
ui.append(grid)
|
||||
|
||||
print("\n--- Test 1: Layer creation (starts dirty) ---")
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
# The layer should be dirty initially
|
||||
# We can't directly check dirty flag from Python, but we verify the system works
|
||||
# A grid built with grid_size= already carries a default tile layer; these are added
|
||||
# on top of it. Layers attached at size (0,0) auto-resize to the grid.
|
||||
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||
grid.add_layer(color_layer)
|
||||
print(" ColorLayer created successfully")
|
||||
|
||||
tile_layer = grid.add_layer("tile", z_index=-2, texture=texture)
|
||||
tile_layer = mcrfpy.TileLayer(name="tile", z_index=-2, texture=texture)
|
||||
grid.add_layer(tile_layer)
|
||||
print(" TileLayer created successfully")
|
||||
print(" PASS: Layers created")
|
||||
|
||||
print("\n--- Test 2: Fill operations work ---")
|
||||
# Fill with some data
|
||||
check("ColorLayer attached and retrievable by name", grid.layer("color") is not None)
|
||||
check("TileLayer attached and retrievable by name", grid.layer("tile") is not None)
|
||||
check("layer auto-resized to grid", color_layer.grid_size == (50, 40),
|
||||
f"got {color_layer.grid_size}")
|
||||
|
||||
print("\n--- Test 2: Fill operations work (and mark the layer dirty) ---")
|
||||
color_layer.fill(mcrfpy.Color(128, 0, 128, 64))
|
||||
print(" ColorLayer filled with purple overlay")
|
||||
check("ColorLayer.fill wrote every cell", color_layer.at((0, 0)) == mcrfpy.Color(128, 0, 128, 64)
|
||||
and color_layer.at((49, 39)) == mcrfpy.Color(128, 0, 128, 64),
|
||||
f"got {color_layer.at((0, 0))} / {color_layer.at((49, 39))}")
|
||||
|
||||
tile_layer.fill(5) # Fill with tile index 5
|
||||
print(" TileLayer filled with tile index 5")
|
||||
print(" PASS: Fill operations completed")
|
||||
tile_layer.fill(5)
|
||||
check("TileLayer.fill wrote every cell",
|
||||
tile_layer.at((0, 0)) == 5 and tile_layer.at((49, 39)) == 5,
|
||||
f"got {tile_layer.at((0, 0))} / {tile_layer.at((49, 39))}")
|
||||
|
||||
print("\n--- Test 3: Cell set operations work ---")
|
||||
# Set individual cells
|
||||
color_layer.set(10, 10, mcrfpy.Color(255, 255, 0, 128))
|
||||
color_layer.set(11, 10, mcrfpy.Color(255, 255, 0, 128))
|
||||
color_layer.set(10, 11, mcrfpy.Color(255, 255, 0, 128))
|
||||
color_layer.set(11, 11, mcrfpy.Color(255, 255, 0, 128))
|
||||
print(" Set 4 cells in ColorLayer to yellow")
|
||||
yellow = mcrfpy.Color(255, 255, 0, 128)
|
||||
for cell in [(10, 10), (11, 10), (10, 11), (11, 11)]:
|
||||
color_layer.set(cell, yellow)
|
||||
check("ColorLayer.set updated the 4 target cells",
|
||||
all(color_layer.at(c) == yellow for c in [(10, 10), (11, 10), (10, 11), (11, 11)]))
|
||||
check("ColorLayer.set left neighbours untouched",
|
||||
color_layer.at((12, 10)) == mcrfpy.Color(128, 0, 128, 64),
|
||||
f"got {color_layer.at((12, 10))}")
|
||||
|
||||
tile_layer.set(15, 15, 10)
|
||||
tile_layer.set(16, 15, 11)
|
||||
tile_layer.set(15, 16, 10)
|
||||
tile_layer.set(16, 16, 11)
|
||||
print(" Set 4 cells in TileLayer to different tiles")
|
||||
print(" PASS: Cell set operations completed")
|
||||
for cell, idx in [((15, 15), 10), ((16, 15), 11), ((15, 16), 10), ((16, 16), 11)]:
|
||||
tile_layer.set(cell, idx)
|
||||
check("TileLayer.set updated the 4 target cells",
|
||||
tile_layer.at((15, 15)) == 10 and tile_layer.at((16, 15)) == 11
|
||||
and tile_layer.at((15, 16)) == 10 and tile_layer.at((16, 16)) == 11)
|
||||
check("TileLayer.set(-1) clears a tile",
|
||||
(tile_layer.set((20, 20), -1), tile_layer.at((20, 20)))[1] == -1,
|
||||
f"got {tile_layer.at((20, 20))}")
|
||||
|
||||
print("\n--- Test 4: Texture change on TileLayer ---")
|
||||
# Create a second texture and assign it
|
||||
texture2 = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
|
||||
# Note: the .texture getter returns a fresh wrapper each call and Texture has no
|
||||
# __eq__, so identity/equality can't be asserted -- compare the source path instead.
|
||||
texture2 = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16)
|
||||
tile_layer.texture = texture2
|
||||
print(" Changed TileLayer texture")
|
||||
check("TileLayer.texture accepts a new texture",
|
||||
tile_layer.texture.source == "assets/kenney_TD_MR_IP.png",
|
||||
f"got {tile_layer.texture.source}")
|
||||
|
||||
# Set back to original
|
||||
tile_layer.texture = texture
|
||||
print(" Restored original texture")
|
||||
print(" PASS: Texture changes work")
|
||||
check("TileLayer.texture restores the original",
|
||||
tile_layer.texture.source == "assets/kenney_tinydungeon.png",
|
||||
f"got {tile_layer.texture.source}")
|
||||
|
||||
print("\n--- Test 5: Viewport changes (should use cached texture) ---")
|
||||
# Pan around - these should NOT cause layer re-renders (just blit different region)
|
||||
print("\n--- Test 5: Dirty flags actually invalidate the cached RenderTexture ---")
|
||||
# This is the core of #148. The grid caches its composed output; a mutation that does
|
||||
# not set the dirty flag yields a byte-identical render (a stale cache).
|
||||
# The ColorLayer (z=-1) composites OVER the TileLayer (z=-2), so it is kept
|
||||
# translucent -- an opaque fill would occlude the tile layer and make the TileLayer
|
||||
# invalidation checks below vacuous.
|
||||
base = render_hash()
|
||||
repeat = render_hash()
|
||||
check("re-rendering an unchanged grid is stable", base == repeat)
|
||||
|
||||
color_layer.fill(mcrfpy.Color(255, 0, 0, 64))
|
||||
after_fill = render_hash()
|
||||
check("fill() invalidates the cache (render changed)", after_fill != base)
|
||||
|
||||
color_layer.set((0, 0), mcrfpy.Color(0, 255, 0, 64))
|
||||
after_set = render_hash()
|
||||
check("set() invalidates the cache (render changed)", after_set != after_fill)
|
||||
|
||||
tile_layer.fill(7)
|
||||
after_tile_fill = render_hash()
|
||||
check("TileLayer.fill() invalidates the cache", after_tile_fill != after_set)
|
||||
|
||||
tile_layer.set((5, 5), 12)
|
||||
after_tile_set = render_hash()
|
||||
check("TileLayer.set() invalidates the cache", after_tile_set != after_tile_fill)
|
||||
|
||||
# Bulk edit path (#328): the view is a 2-D int32 memoryview aliasing layer storage;
|
||||
# on __exit__ the whole layer is conservatively invalidated.
|
||||
with tile_layer.edit() as view:
|
||||
for y in range(40):
|
||||
for x in range(50):
|
||||
view[y, x] = 3
|
||||
check("bulk edit() wrote through to layer storage", tile_layer.at((5, 5)) == 3,
|
||||
f"got {tile_layer.at((5, 5))}")
|
||||
after_edit = render_hash()
|
||||
check("bulk edit() invalidates the cache", after_edit != after_tile_set)
|
||||
|
||||
print("\n--- Test 6: Viewport changes (should use the cached texture) ---")
|
||||
original_center = grid.center
|
||||
print(f" Original center: {original_center}")
|
||||
original_zoom = grid.zoom
|
||||
print(f" Original center: {original_center}, zoom: {original_zoom}")
|
||||
|
||||
# Perform multiple viewport changes
|
||||
for i in range(10):
|
||||
grid.center = (100 + i * 20, 80 + i * 10)
|
||||
print(" Performed 10 center changes")
|
||||
check("center round-trips after 10 changes", grid.center == (280, 170),
|
||||
f"got {grid.center}")
|
||||
panned = render_hash()
|
||||
check("panning the viewport changes the rendered image", panned != after_edit)
|
||||
|
||||
# Zoom changes
|
||||
original_zoom = grid.zoom
|
||||
for z in [1.0, 0.8, 1.2, 0.5, 1.5, 1.0]:
|
||||
grid.zoom = z
|
||||
print(" Performed 6 zoom changes")
|
||||
check("zoom round-trips after 6 changes", grid.zoom == 1.0, f"got {grid.zoom}")
|
||||
|
||||
# Restore
|
||||
# Restoring the viewport must restore the exact same image: the layer content never
|
||||
# changed, so this exercises the cache-hit path (blit a different region, no re-render).
|
||||
grid.center = original_center
|
||||
grid.zoom = original_zoom
|
||||
print(" PASS: Viewport changes completed without crashing")
|
||||
|
||||
print("\n--- Test 6: Performance benchmark ---")
|
||||
# Create a large layer for performance testing
|
||||
perf_grid = mcrfpy.Grid(pos=(50, 50), size=(600, 500), grid_size=(100, 80), texture=texture)
|
||||
ui.append(perf_grid)
|
||||
perf_layer = perf_grid.add_layer("tile", z_index=-1, texture=texture)
|
||||
|
||||
# Fill with data
|
||||
perf_layer.fill(1)
|
||||
|
||||
# Render a frame to build cache
|
||||
mcrfpy.step(0.01)
|
||||
|
||||
# Subsequent viewport changes should be fast (cache hit)
|
||||
start = time.time()
|
||||
for i in range(5):
|
||||
perf_grid.center = (200 + i * 10, 160 + i * 8)
|
||||
viewport_changes = time.time() - start
|
||||
print(f" 5 viewport changes: {viewport_changes*1000:.2f}ms")
|
||||
|
||||
print(" PASS: Performance benchmark completed")
|
||||
restored = render_hash()
|
||||
check("restoring the viewport restores the identical image", restored == after_edit)
|
||||
|
||||
print("\n--- Test 7: Layer visibility toggle ---")
|
||||
# The layer's own render() early-outs on `visible`, so hiding a layer must change the
|
||||
# rendered output. It only does so if the setter invalidates the grid's cache.
|
||||
visible_hash = render_hash()
|
||||
color_layer.visible = False
|
||||
print(" ColorLayer hidden")
|
||||
check("visible attribute round-trips to False", color_layer.visible is False)
|
||||
hidden_hash = render_hash()
|
||||
check("hiding a layer changes the rendered image (dirty flag set)",
|
||||
hidden_hash != visible_hash,
|
||||
"-- layer.visible setter does not invalidate the grid cache (see notes)")
|
||||
|
||||
color_layer.visible = True
|
||||
print(" ColorLayer shown")
|
||||
print(" PASS: Visibility toggle works")
|
||||
check("visible attribute round-trips to True", color_layer.visible is True)
|
||||
reshown_hash = render_hash()
|
||||
check("re-showing a layer restores the rendered image",
|
||||
reshown_hash == visible_hash,
|
||||
"-- layer.visible setter does not invalidate the grid cache (see notes)")
|
||||
|
||||
print("\n--- Test 8: Large grid stress test ---")
|
||||
# Test with maximum size grid to ensure texture caching works
|
||||
stress_scene = mcrfpy.Scene("stress")
|
||||
stress_grid = mcrfpy.Grid(pos=(10, 10), size=(200, 150), grid_size=(200, 150), texture=texture)
|
||||
ui.append(stress_grid)
|
||||
stress_layer = stress_grid.add_layer("color", z_index=-1)
|
||||
stress_scene.children.append(stress_grid)
|
||||
stress_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||
stress_grid.add_layer(stress_layer)
|
||||
|
||||
# This would be 30,000 cells - should handle via caching
|
||||
# 30,000 cells - should be handled via chunked texture caching
|
||||
stress_layer.fill(mcrfpy.Color(0, 100, 200, 100))
|
||||
|
||||
# Set a few specific cells
|
||||
for x in range(10):
|
||||
for y in range(10):
|
||||
stress_layer.set(x, y, mcrfpy.Color(255, 0, 0, 200))
|
||||
stress_layer.set((x, y), mcrfpy.Color(255, 0, 0, 200))
|
||||
|
||||
print(" Created 200x150 grid with 30,000 cells")
|
||||
print(" PASS: Large grid handled successfully")
|
||||
check("30,000-cell layer filled", stress_layer.at((199, 149)) == mcrfpy.Color(0, 100, 200, 100),
|
||||
f"got {stress_layer.at((199, 149))}")
|
||||
check("30,000-cell layer accepts per-cell sets",
|
||||
stress_layer.at((9, 9)) == mcrfpy.Color(255, 0, 0, 200),
|
||||
f"got {stress_layer.at((9, 9))}")
|
||||
|
||||
mcrfpy.current_scene = stress_scene
|
||||
mcrfpy.step(0.016)
|
||||
stress_render = render_hash()
|
||||
check("large grid renders without crashing", len(stress_render) == 64)
|
||||
|
||||
# A static layer must be re-renderable from cache: identical bytes, no corruption.
|
||||
check("static large layer renders identically from cache",
|
||||
render_hash() == stress_render)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
if failures:
|
||||
print(f"FAIL - {len(failures)} check(s) failed:")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
print("=" * 60)
|
||||
sys.exit(1)
|
||||
|
||||
print("All tests PASSED")
|
||||
print("=" * 60)
|
||||
print("\nNote: Dirty flag behavior is internal - tests verify API works")
|
||||
print("Actual caching benefits are measured by render performance.")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Regression test: entity gridstate must resize when moving between grids.
|
||||
"""Regression test: entity perspective map must resize when moving between grids.
|
||||
|
||||
Issues #258-#263, #274, #276, #278: UIEntity gridstate heap overflows.
|
||||
|
||||
|
|
@ -7,139 +7,159 @@ moved from a small grid to a larger grid via ANY transfer method, gridstate
|
|||
kept the old size. Code then iterated using the new grid's dimensions,
|
||||
writing past the vector's end.
|
||||
|
||||
Fix: ensureGridstate() unconditionally checks gridstate.size() against
|
||||
grid dimensions and resizes if they don't match. Applied to all transfer
|
||||
methods: set_grid, append, extend, insert, setitem, slice assignment.
|
||||
Fix: the per-entity visibility buffer unconditionally checks its size against
|
||||
the grid dimensions and reallocates if they don't match. Exercised here through
|
||||
every transfer method: set_grid, append, extend, insert, setitem, slice assign.
|
||||
|
||||
Also tests #274: spatial_hash.remove() must be called when removing
|
||||
entities from grids via set_grid(None) or set_grid(other_grid).
|
||||
|
||||
API NOTE (#294): entity.gridstate is now entity.perspective_map, a bounds-checked
|
||||
DiscreteMap (UNKNOWN/DISCOVERED/VISIBLE) carrying its own .size, and it is *lazy* --
|
||||
a transfer marks it stale and the next update_visibility() sizes it to the new grid
|
||||
(see UIEntity::updateVisibility, src/UIEntity.cpp:51). So each case below transfers,
|
||||
then calls update_visibility(), then asserts the buffer matches the new grid and that
|
||||
cells only reachable in the NEW (larger) grid are addressable. Under the old bug those
|
||||
reads/writes ran off the end of the old buffer.
|
||||
"""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
def open_grid(size):
|
||||
"""Grid of `size` x `size` with all cells transparent/walkable, FOV enabled."""
|
||||
grid = mcrfpy.Grid(grid_size=(size, size))
|
||||
data = grid.grid_data
|
||||
for x in range(size):
|
||||
for y in range(size):
|
||||
point = data.at(x, y)
|
||||
point.transparent = True
|
||||
point.walkable = True
|
||||
data.fov_radius = 4
|
||||
return grid
|
||||
|
||||
def check_sized_to(entity, grid, label):
|
||||
"""Buffer must match the grid, and the grid's far corner must be addressable."""
|
||||
entity.update_visibility()
|
||||
pmap = entity.perspective_map
|
||||
w, h = grid.grid_data.grid_w, grid.grid_data.grid_h
|
||||
assert pmap.size == (w, h), f"{label}: expected {(w, h)}, got {pmap.size}"
|
||||
# Under #258 this read walked off the end of the stale, smaller buffer.
|
||||
pmap.get(w - 1, h - 1)
|
||||
|
||||
def test_set_grid():
|
||||
"""entity.grid = new_grid resizes gridstate"""
|
||||
small = mcrfpy.Grid(grid_size=(10, 10))
|
||||
large = mcrfpy.Grid(grid_size=(50, 50))
|
||||
"""entity.grid = new_grid resizes the perspective map"""
|
||||
small = open_grid(10)
|
||||
large = open_grid(50)
|
||||
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small)
|
||||
|
||||
small.perspective = entity
|
||||
small.fov_radius = 4
|
||||
entity.update_visibility()
|
||||
|
||||
gs = entity.gridstate
|
||||
assert len(gs) == 100, f"Expected 100, got {len(gs)}"
|
||||
check_sized_to(entity, small, "set_grid/small")
|
||||
|
||||
entity.grid = large
|
||||
gs = entity.gridstate
|
||||
assert len(gs) == 2500, f"Expected 2500, got {len(gs)}"
|
||||
|
||||
large.perspective = entity
|
||||
large.fov_radius = 8
|
||||
entity.update_visibility()
|
||||
check_sized_to(entity, large, "set_grid/large")
|
||||
print(" PASS: set_grid")
|
||||
|
||||
def test_append():
|
||||
"""grid.entities.append(entity) resizes gridstate"""
|
||||
small = mcrfpy.Grid(grid_size=(10, 10))
|
||||
large = mcrfpy.Grid(grid_size=(40, 40))
|
||||
"""grid.entities.append(entity) resizes the perspective map"""
|
||||
small = open_grid(10)
|
||||
large = open_grid(40)
|
||||
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
|
||||
entity.update_visibility()
|
||||
|
||||
gs = entity.gridstate
|
||||
assert len(gs) == 100, f"Expected 100, got {len(gs)}"
|
||||
check_sized_to(entity, small, "append/small")
|
||||
|
||||
large.entities.append(entity)
|
||||
gs = entity.gridstate
|
||||
assert len(gs) == 1600, f"Expected 1600, got {len(gs)}"
|
||||
check_sized_to(entity, large, "append/large")
|
||||
print(" PASS: append")
|
||||
|
||||
def test_extend():
|
||||
"""grid.entities.extend([entity]) resizes gridstate"""
|
||||
small = mcrfpy.Grid(grid_size=(10, 10))
|
||||
large = mcrfpy.Grid(grid_size=(30, 30))
|
||||
"""grid.entities.extend([entity]) resizes the perspective map"""
|
||||
small = open_grid(10)
|
||||
large = open_grid(30)
|
||||
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
|
||||
entity.update_visibility()
|
||||
check_sized_to(entity, small, "extend/small")
|
||||
|
||||
large.entities.extend([entity])
|
||||
gs = entity.gridstate
|
||||
assert len(gs) == 900, f"Expected 900, got {len(gs)}"
|
||||
check_sized_to(entity, large, "extend/large")
|
||||
print(" PASS: extend")
|
||||
|
||||
def test_insert():
|
||||
"""grid.entities.insert(0, entity) resizes gridstate"""
|
||||
small = mcrfpy.Grid(grid_size=(10, 10))
|
||||
large = mcrfpy.Grid(grid_size=(25, 25))
|
||||
"""grid.entities.insert(0, entity) resizes the perspective map"""
|
||||
small = open_grid(10)
|
||||
large = open_grid(25)
|
||||
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
|
||||
entity.update_visibility()
|
||||
check_sized_to(entity, small, "insert/small")
|
||||
|
||||
large.entities.insert(0, entity)
|
||||
gs = entity.gridstate
|
||||
assert len(gs) == 625, f"Expected 625, got {len(gs)}"
|
||||
check_sized_to(entity, large, "insert/large")
|
||||
print(" PASS: insert")
|
||||
|
||||
def test_setitem():
|
||||
"""grid.entities[0] = entity resizes gridstate"""
|
||||
small = mcrfpy.Grid(grid_size=(10, 10))
|
||||
large = mcrfpy.Grid(grid_size=(20, 20))
|
||||
"""grid.entities[0] = entity resizes the perspective map"""
|
||||
small = open_grid(10)
|
||||
large = open_grid(20)
|
||||
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
|
||||
entity.update_visibility()
|
||||
check_sized_to(entity, small, "setitem/small")
|
||||
|
||||
# Need a placeholder entity in large grid first
|
||||
placeholder = mcrfpy.Entity(grid_pos=(0, 0), grid=large)
|
||||
large.entities[0] = entity
|
||||
gs = entity.gridstate
|
||||
assert len(gs) == 400, f"Expected 400, got {len(gs)}"
|
||||
assert len(large.entities) == 1
|
||||
check_sized_to(entity, large, "setitem/large")
|
||||
print(" PASS: setitem")
|
||||
|
||||
def test_slice_assign():
|
||||
"""grid.entities[0:1] = [entity] resizes gridstate"""
|
||||
small = mcrfpy.Grid(grid_size=(10, 10))
|
||||
large = mcrfpy.Grid(grid_size=(35, 35))
|
||||
"""grid.entities[0:1] = [entity] resizes the perspective map"""
|
||||
small = open_grid(10)
|
||||
large = open_grid(35)
|
||||
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
|
||||
entity.update_visibility()
|
||||
check_sized_to(entity, small, "slice/small")
|
||||
|
||||
placeholder = mcrfpy.Entity(grid_pos=(0, 0), grid=large)
|
||||
large.entities[0:1] = [entity]
|
||||
gs = entity.gridstate
|
||||
assert len(gs) == 1225, f"Expected 1225, got {len(gs)}"
|
||||
check_sized_to(entity, large, "slice/large")
|
||||
print(" PASS: slice_assign")
|
||||
|
||||
def test_update_visibility_after_transfer():
|
||||
"""update_visibility works correctly after all transfer methods"""
|
||||
grids = [mcrfpy.Grid(grid_size=(s, s)) for s in (5, 80, 3, 60, 10, 100)]
|
||||
"""update_visibility works correctly after repeated grow/shrink transfers"""
|
||||
grids = [open_grid(s) for s in (5, 80, 3, 60, 10, 100)]
|
||||
entity = mcrfpy.Entity(grid_pos=(2, 2), grid=grids[0])
|
||||
|
||||
for g in grids:
|
||||
entity.grid = g
|
||||
g.perspective = entity
|
||||
g.fov_radius = 4
|
||||
entity.update_visibility()
|
||||
gs = entity.gridstate
|
||||
expected = g.grid_w * g.grid_h
|
||||
assert len(gs) == expected, f"Expected {expected}, got {len(gs)}"
|
||||
check_sized_to(entity, g, f"cycle/{g.grid_data.grid_w}")
|
||||
print(" PASS: update_visibility_after_transfer")
|
||||
|
||||
def test_at_after_transfer():
|
||||
"""entity.at(x, y) works correctly after grid transfer"""
|
||||
small = mcrfpy.Grid(grid_size=(10, 10))
|
||||
large = mcrfpy.Grid(grid_size=(50, 50))
|
||||
small = open_grid(10)
|
||||
large = open_grid(50)
|
||||
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small)
|
||||
entity.update_visibility()
|
||||
|
||||
entity.grid = large
|
||||
# Access a cell that would be out of bounds for the small grid
|
||||
large.perspective = entity
|
||||
# Stand on a cell that is out of bounds for the small grid entirely.
|
||||
entity.grid_pos = (30, 30)
|
||||
entity.update_visibility()
|
||||
|
||||
# at() returns the GridPoint only when the cell is VISIBLE to this entity.
|
||||
state = entity.at(30, 30)
|
||||
assert state is not None
|
||||
assert state is not None, "entity's own cell must be visible after transfer"
|
||||
assert tuple(state.grid_pos) == (30, 30), f"got {tuple(state.grid_pos)}"
|
||||
# Far side of the new grid: addressable (not a heap read), just not visible.
|
||||
assert entity.at(5, 5) is None, "cell outside FOV must report as not visible"
|
||||
assert entity.perspective_map.get(49, 49) == mcrfpy.Perspective.UNKNOWN
|
||||
print(" PASS: at_after_transfer")
|
||||
|
||||
def test_set_grid_none():
|
||||
"""entity.grid = None properly removes entity (tests #274)"""
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
grid = open_grid(10)
|
||||
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid)
|
||||
assert len(grid.entities) == 1
|
||||
entity.grid = None
|
||||
assert len(grid.entities) == 0
|
||||
assert entity.grid is None
|
||||
print(" PASS: set_grid_none")
|
||||
|
||||
def test_stress():
|
||||
|
|
@ -150,26 +170,32 @@ def test_stress():
|
|||
entity.grid = small_g
|
||||
small_g.perspective = entity
|
||||
entity.update_visibility()
|
||||
assert entity.perspective_map.size == (5, 5)
|
||||
|
||||
big_g = mcrfpy.Grid(grid_size=(80, 80))
|
||||
entity.grid = big_g
|
||||
big_g.perspective = entity
|
||||
entity.update_visibility()
|
||||
assert entity.perspective_map.size == (80, 80)
|
||||
|
||||
frames = [mcrfpy.Frame() for _ in range(10)]
|
||||
del frames
|
||||
print(" PASS: stress")
|
||||
|
||||
print("Testing gridstate resize across transfer methods...")
|
||||
test_set_grid()
|
||||
test_append()
|
||||
test_extend()
|
||||
test_insert()
|
||||
test_setitem()
|
||||
test_slice_assign()
|
||||
test_update_visibility_after_transfer()
|
||||
test_at_after_transfer()
|
||||
test_set_grid_none()
|
||||
test_stress()
|
||||
print("PASS: all gridstate resize tests passed")
|
||||
print("Testing perspective map resize across transfer methods...")
|
||||
try:
|
||||
test_set_grid()
|
||||
test_append()
|
||||
test_extend()
|
||||
test_insert()
|
||||
test_setitem()
|
||||
test_slice_assign()
|
||||
test_update_visibility_after_transfer()
|
||||
test_at_after_transfer()
|
||||
test_set_grid_none()
|
||||
test_stress()
|
||||
except AssertionError as e:
|
||||
print(f"FAIL: {e}")
|
||||
sys.exit(1)
|
||||
print("PASS: all perspective map resize tests passed")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ pointers would dangle.
|
|||
|
||||
Fix: Remove raw pointers. Store (grid, x, y) coordinates and compute
|
||||
the data address on each property access.
|
||||
|
||||
API update (current contract): the per-entity GridPointState vector is gone.
|
||||
Its successor is entity.perspective_map, a DiscreteMap of mcrfpy.Perspective
|
||||
(UNKNOWN / DISCOVERED / VISIBLE), and entity.at(x, y) now returns the grid's
|
||||
GridPoint only when that cell is VISIBLE to the entity (None otherwise).
|
||||
The dangle scenario is preserved: the perspective map is reallocated when the
|
||||
entity transfers to a differently-sized grid, and previously-obtained
|
||||
GridPoint / DiscreteMap wrappers must remain safe to use afterward.
|
||||
"""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
|
@ -30,39 +38,63 @@ def test_gridpoint_grid_pos():
|
|||
assert pos == (7, 3), f"Expected (7, 3), got {pos}"
|
||||
print(" PASS: gridpoint_grid_pos")
|
||||
|
||||
def test_gridpointstate_access():
|
||||
"""entity.at(x,y) returns working GridPointState via coordinate lookup"""
|
||||
def test_perspective_map_access():
|
||||
"""entity.at(x,y) / entity.perspective_map use coordinate lookup"""
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid)
|
||||
entity.update_visibility()
|
||||
|
||||
state = entity.at(5, 5)
|
||||
# Should be accessible
|
||||
assert state is not None
|
||||
# visible/discovered should be boolean
|
||||
assert isinstance(state.visible, bool)
|
||||
assert isinstance(state.discovered, bool)
|
||||
print(" PASS: gridpointstate_access")
|
||||
pm = entity.perspective_map
|
||||
assert pm.size == (10, 10), f"Expected (10, 10), got {pm.size}"
|
||||
|
||||
def test_gridpointstate_after_grid_transfer():
|
||||
"""GridPointState access works after entity transfers to new grid"""
|
||||
# The entity's own cell is VISIBLE; entity.at() hands back the GridPoint.
|
||||
assert pm.get((5, 5)) == mcrfpy.Perspective.VISIBLE
|
||||
gp = entity.at(5, 5)
|
||||
assert gp is not None
|
||||
assert gp.grid_pos == (5, 5), f"Expected (5, 5), got {gp.grid_pos}"
|
||||
|
||||
# A cell outside the entity's FOV is UNKNOWN, and at() gates on visibility.
|
||||
assert pm.get((9, 0)) == mcrfpy.Perspective.UNKNOWN
|
||||
assert entity.at(9, 0) is None
|
||||
print(" PASS: perspective_map_access")
|
||||
|
||||
def test_perspective_map_after_grid_transfer():
|
||||
"""Perspective/GridPoint access works after entity transfers to new grid"""
|
||||
small = mcrfpy.Grid(grid_size=(10, 10))
|
||||
large = mcrfpy.Grid(grid_size=(20, 20))
|
||||
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small)
|
||||
entity.update_visibility()
|
||||
|
||||
# Get state on small grid
|
||||
state1 = entity.at(3, 3)
|
||||
assert state1 is not None
|
||||
# Wrappers obtained while on the small grid
|
||||
gp_small = small.grid_data.at(3, 3)
|
||||
pm_small = entity.perspective_map
|
||||
assert pm_small.size == (10, 10)
|
||||
|
||||
# Transfer to large grid (gridstate resizes)
|
||||
# Transfer to large grid: the entity's perspective map is reallocated.
|
||||
entity.grid = large
|
||||
entity.update_visibility()
|
||||
|
||||
# Access a cell that didn't exist on small grid
|
||||
state2 = entity.at(15, 15)
|
||||
assert state2 is not None
|
||||
print(" PASS: gridpointstate_after_grid_transfer")
|
||||
# entity.grid is the shared GridData, not the view (#313/#361)
|
||||
assert entity.grid is large.grid_data
|
||||
|
||||
# The new map covers the larger grid, and coordinate lookup reaches cells
|
||||
# that did not exist on the small grid.
|
||||
pm_large = entity.perspective_map
|
||||
assert pm_large.size == (20, 20), f"Expected (20, 20), got {pm_large.size}"
|
||||
assert pm_large.get((15, 15)) == mcrfpy.Perspective.UNKNOWN
|
||||
assert pm_large.get((5, 5)) == mcrfpy.Perspective.VISIBLE
|
||||
assert entity.at(5, 5) is not None
|
||||
|
||||
# The pre-transfer wrappers must not dangle: they still describe the small
|
||||
# grid / old map and remain safe to read and write.
|
||||
assert pm_small.size == (10, 10)
|
||||
assert pm_small.get((3, 3)) in (mcrfpy.Perspective.UNKNOWN,
|
||||
mcrfpy.Perspective.DISCOVERED,
|
||||
mcrfpy.Perspective.VISIBLE)
|
||||
assert gp_small.grid_pos == (3, 3)
|
||||
gp_small.walkable = True
|
||||
assert small.grid_data.at(3, 3).walkable == True
|
||||
print(" PASS: perspective_map_after_grid_transfer")
|
||||
|
||||
def test_gridpoint_subscript():
|
||||
"""grid[x, y] returns working GridPoint"""
|
||||
|
|
@ -72,26 +104,33 @@ def test_gridpoint_subscript():
|
|||
assert grid.at(3, 4).walkable == True
|
||||
print(" PASS: gridpoint_subscript")
|
||||
|
||||
def test_gridstate_list():
|
||||
"""entity.gridstate returns list with visible/discovered attrs"""
|
||||
def test_perspective_map_all_cells():
|
||||
"""entity.perspective_map covers every cell of the grid"""
|
||||
grid = mcrfpy.Grid(grid_size=(5, 5))
|
||||
entity = mcrfpy.Entity(grid_pos=(2, 2), grid=grid)
|
||||
entity.update_visibility()
|
||||
|
||||
gs = entity.gridstate
|
||||
assert len(gs) == 25, f"Expected 25, got {len(gs)}"
|
||||
# Each element should have visible and discovered
|
||||
for state in gs:
|
||||
assert hasattr(state, 'visible')
|
||||
assert hasattr(state, 'discovered')
|
||||
print(" PASS: gridstate_list")
|
||||
pm = entity.perspective_map
|
||||
assert pm.size == (5, 5), f"Expected (5, 5), got {pm.size}"
|
||||
|
||||
print("Testing GridPoint/GridPointState coordinate-based access...")
|
||||
# Every one of the 25 cells is addressable and holds a Perspective member
|
||||
valid = (mcrfpy.Perspective.UNKNOWN, mcrfpy.Perspective.DISCOVERED,
|
||||
mcrfpy.Perspective.VISIBLE)
|
||||
count = 0
|
||||
for y in range(5):
|
||||
for x in range(5):
|
||||
state = pm.get((x, y))
|
||||
assert state in valid, f"({x}, {y}) has bad state {state}"
|
||||
count += 1
|
||||
assert count == 25, f"Expected 25, got {count}"
|
||||
print(" PASS: perspective_map_all_cells")
|
||||
|
||||
print("Testing GridPoint/perspective_map coordinate-based access...")
|
||||
test_gridpoint_access()
|
||||
test_gridpoint_grid_pos()
|
||||
test_gridpointstate_access()
|
||||
test_gridpointstate_after_grid_transfer()
|
||||
test_perspective_map_access()
|
||||
test_perspective_map_after_grid_transfer()
|
||||
test_gridpoint_subscript()
|
||||
test_gridstate_list()
|
||||
print("PASS: all GridPoint/GridPointState tests passed")
|
||||
test_perspective_map_all_cells()
|
||||
print("PASS: all GridPoint/perspective_map tests passed")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
133
tests/regression/issue_341_metrics_counters_test.py
Normal file
133
tests/regression/issue_341_metrics_counters_test.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""
|
||||
Regression test for issue #341.
|
||||
|
||||
get_metrics() reported 0 for every render counter:
|
||||
|
||||
* draw_calls / ui_elements / visible_elements WERE incremented -- but only inside
|
||||
render(), which runs after every Python callback, while the counters were zeroed
|
||||
at the top of the frame. Python was structurally unable to observe a nonzero
|
||||
value. Render counters are now published at the end of the render pass.
|
||||
|
||||
* grid_cells_rendered / entities_rendered / total_entities / grid_render_time /
|
||||
entity_render_time were never incremented ANYWHERE -- dead code since the
|
||||
GridView refactor. Re-instrumented in UIGridView::render().
|
||||
|
||||
Also locks two riders: frame_time is milliseconds (the docstring claimed seconds),
|
||||
and fps is no longer inflated by the zero-filled history buffer on early frames.
|
||||
|
||||
Model under test (#350): step() is the clock and never renders; rendering is
|
||||
orthogonal and costs zero simulation time. So we drive time with step() and force a
|
||||
render with a screenshot, then read the metrics that render published.
|
||||
"""
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
import sys
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
if condition:
|
||||
print(f" PASS: {label}")
|
||||
else:
|
||||
print(f" FAIL: {label} {detail}")
|
||||
failures.append(label)
|
||||
|
||||
|
||||
scene = mcrfpy.Scene("issue341")
|
||||
ui = scene.children
|
||||
|
||||
# A grid with a layer + entities, so the grid counters have something to count.
|
||||
grid = mcrfpy.Grid(grid_size=(20, 20), pos=(0, 0), size=(320, 320))
|
||||
layer = mcrfpy.TileLayer("ground", 0)
|
||||
grid.add_layer(layer)
|
||||
for x in range(20):
|
||||
for y in range(20):
|
||||
layer.set((x, y), 0)
|
||||
|
||||
for i in range(6):
|
||||
grid.entities.append(mcrfpy.Entity(grid_pos=(i, i)))
|
||||
|
||||
ui.append(grid)
|
||||
|
||||
# Plain UI elements, so ui_elements / draw_calls have something to count.
|
||||
for i in range(4):
|
||||
ui.append(mcrfpy.Frame(pos=(400 + i * 20, 10), size=(15, 15)))
|
||||
ui.append(mcrfpy.Caption(pos=(400, 200), text="metrics"))
|
||||
|
||||
mcrfpy.current_scene = scene
|
||||
|
||||
# Advance the clock a few times. step() must NOT render, so this alone must leave
|
||||
# the render counters untouched.
|
||||
for _ in range(5):
|
||||
mcrfpy.step(0.016)
|
||||
|
||||
print("1. step() advances the sim clock but renders nothing")
|
||||
m = mcrfpy.get_metrics()
|
||||
check("current_frame advanced under step()", m["current_frame"] >= 5,
|
||||
f"got {m['current_frame']}")
|
||||
check("frame_time is set by step()", m["frame_time"] > 0, f"got {m['frame_time']}")
|
||||
check("draw_calls still 0 (step does not render)", m["draw_calls"] == 0,
|
||||
f"got {m['draw_calls']}")
|
||||
|
||||
# Force a render. This costs zero simulation time and publishes the render counters.
|
||||
shot = os.path.join(tempfile.gettempdir(), "issue341_metrics.png")
|
||||
frame_before = mcrfpy.get_metrics()["current_frame"]
|
||||
automation.screenshot(shot)
|
||||
m = mcrfpy.get_metrics()
|
||||
|
||||
print("2. Rendering costs zero simulation time")
|
||||
check("current_frame did not advance from rendering",
|
||||
m["current_frame"] == frame_before,
|
||||
f"{frame_before} -> {m['current_frame']}")
|
||||
|
||||
print("3. Render counters are observable from Python (were always 0)")
|
||||
check("draw_calls > 0", m["draw_calls"] > 0, f"got {m['draw_calls']}")
|
||||
check("ui_elements > 0", m["ui_elements"] > 0, f"got {m['ui_elements']}")
|
||||
check("visible_elements > 0", m["visible_elements"] > 0, f"got {m['visible_elements']}")
|
||||
|
||||
print("4. Grid counters are re-instrumented (were dead code)")
|
||||
check("grid_cells_rendered > 0", m["grid_cells_rendered"] > 0,
|
||||
f"got {m['grid_cells_rendered']}")
|
||||
check("entities_rendered > 0", m["entities_rendered"] > 0,
|
||||
f"got {m['entities_rendered']}")
|
||||
check("total_entities == 6", m["total_entities"] == 6, f"got {m['total_entities']}")
|
||||
check("entities_rendered <= total_entities",
|
||||
m["entities_rendered"] <= m["total_entities"])
|
||||
# A cell "render" is counted per cell per layer drawn: the 20x20 grid is fully on
|
||||
# screen, so it is exactly 400 * (number of layers).
|
||||
expected_cells = 400 * len(grid.layers)
|
||||
check(f"grid_cells_rendered == 400 * {len(grid.layers)} layers",
|
||||
m["grid_cells_rendered"] == expected_cells,
|
||||
f"got {m['grid_cells_rendered']}, expected {expected_cells}")
|
||||
|
||||
print("5. frame_time is in milliseconds, not seconds")
|
||||
ft = m["frame_time"]
|
||||
# step(0.016) -> 16ms. In seconds this would read 0.016 and fail the lower bound.
|
||||
check("frame_time ~= 16ms for step(0.016)", 10.0 <= ft <= 25.0, f"got {ft}")
|
||||
|
||||
print("6. fps is sane on early frames (was inflated by the zero-filled history)")
|
||||
fps = m["fps"]
|
||||
check("fps > 0", fps > 0, f"got {fps}")
|
||||
# Pre-fix, dividing by a mostly-zero 60-slot buffer inflated this enormously.
|
||||
# step(0.016) is 62.5 fps; allow slack but nothing absurd.
|
||||
check("fps is not absurdly inflated", fps < 1000, f"got {fps}")
|
||||
|
||||
print("7. Timing breakdowns are present")
|
||||
check("grid_render_time >= 0", m["grid_render_time"] >= 0.0)
|
||||
check("entity_render_time >= 0", m["entity_render_time"] >= 0.0)
|
||||
|
||||
if os.path.exists(shot):
|
||||
os.remove(shot)
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"FAIL - {len(failures)} check(s) failed:")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
@ -26,9 +26,13 @@ def main():
|
|||
c.walkable = (i % 2 == 0)
|
||||
assert g.at(0, 0).walkable is True
|
||||
|
||||
# entity.grid round-trips to the same Grid object
|
||||
# entity.grid round-trips to the owning GridData wrapper.
|
||||
# (#313/#361: entity.grid returns the shared GridData, NOT the Grid view.
|
||||
# The #348 cache guarantee is what matters here: the same wrapper object
|
||||
# comes back every time, and it is the *same* wrapper the view hands out.)
|
||||
e = mcrfpy.Entity((1, 1), grid=g)
|
||||
assert e.grid is g, "entity.grid should be the owning Grid"
|
||||
assert e.grid is g.grid_data, "entity.grid should be the owning GridData"
|
||||
assert e.grid is e.grid, "entity.grid must have stable wrapper identity"
|
||||
|
||||
# Reassigning grid_data invalidates the cache: new wrapper for new data
|
||||
g2 = mcrfpy.Grid(grid_size=(4, 4), pos=(0, 0), size=(40, 40))
|
||||
|
|
|
|||
88
tests/regression/issue_350_step_frame_parity_test.py
Normal file
88
tests/regression/issue_350_step_frame_parity_test.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""
|
||||
Regression test for issue #350.
|
||||
|
||||
Headless mcrfpy.step() advanced animations and timers only. It never called
|
||||
McRFPy_API::updatePythonScenes(), which lives solely in doFrame() -- so under step():
|
||||
|
||||
* Scene.update(dt) overrides NEVER fired (the filed bug)
|
||||
* the C++ scene update never ran
|
||||
* scene transitions never progressed or completed
|
||||
* current_frame never advanced
|
||||
|
||||
Any game with per-frame logic in update() was untestable headless.
|
||||
|
||||
step() is now a full SIMULATION frame: everything doFrame() does except render and
|
||||
input. Rendering stays deliberately off the clock -- it costs zero simulation time
|
||||
(see issue_341 test), so step() must still render nothing.
|
||||
"""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
if condition:
|
||||
print(f" PASS: {label}")
|
||||
else:
|
||||
print(f" FAIL: {label} {detail}")
|
||||
failures.append(label)
|
||||
|
||||
|
||||
# ------------------------------------------------------- Scene.update under step()
|
||||
print("1. Scene.update(dt) fires under step() (the filed bug)")
|
||||
|
||||
|
||||
class CountingScene(mcrfpy.Scene):
|
||||
def __init__(self, name):
|
||||
super().__init__(name)
|
||||
self.update_count = 0
|
||||
self.dt_total = 0.0
|
||||
|
||||
def update(self, dt):
|
||||
self.update_count += 1
|
||||
self.dt_total += dt
|
||||
|
||||
|
||||
scene = CountingScene("issue350")
|
||||
mcrfpy.current_scene = scene
|
||||
|
||||
for _ in range(5):
|
||||
mcrfpy.step(0.016)
|
||||
|
||||
check("update() fired once per step()", scene.update_count == 5,
|
||||
f"got {scene.update_count}")
|
||||
check("update() received the dt", abs(scene.dt_total - 5 * 0.016) < 1e-4,
|
||||
f"got {scene.dt_total}")
|
||||
|
||||
# ------------------------------------------------------------ current_frame advances
|
||||
print("2. current_frame advances under step()")
|
||||
before = mcrfpy.get_metrics()["current_frame"]
|
||||
for _ in range(3):
|
||||
mcrfpy.step(0.016)
|
||||
after = mcrfpy.get_metrics()["current_frame"]
|
||||
check("current_frame advanced by 3", after - before == 3, f"{before} -> {after}")
|
||||
|
||||
# ------------------------------------------------------------------- timers still fire
|
||||
print("3. Timers still fire under step() (unchanged behavior)")
|
||||
fired = []
|
||||
mcrfpy.Timer("t350", lambda timer, rt: fired.append(rt), 50)
|
||||
for _ in range(4):
|
||||
mcrfpy.step(0.02)
|
||||
check("timer fired", len(fired) > 0, f"got {len(fired)} fires")
|
||||
|
||||
# ----------------------------------------------------------- step() must not render
|
||||
print("4. step() still renders nothing (render is off the clock)")
|
||||
m = mcrfpy.get_metrics()
|
||||
check("draw_calls == 0 after step()-only", m["draw_calls"] == 0,
|
||||
f"got {m['draw_calls']}")
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"FAIL - {len(failures)} check(s) failed:")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
107
tests/regression/issue_356_module_attrs_discoverable_test.py
Normal file
107
tests/regression/issue_356_module_attrs_discoverable_test.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""
|
||||
Regression test for issue #356.
|
||||
|
||||
mcrfpy's dynamic module attributes (current_scene, scenes, timers, ...) used to be a
|
||||
PEP-562 module __getattr__. getattr/hasattr worked, but they were absent from
|
||||
dir(mcrfpy) -- and every doc/stub/manifest generator discovers module symbols via
|
||||
dir(), so the single most common idiom in the engine appeared in no generated docs.
|
||||
|
||||
They are now real getset descriptors on type(mcrfpy), plus a __dir__ override
|
||||
(CPython's module.__dir__ reports only __dict__ keys, so descriptors alone are not
|
||||
enough -- that is the trap this test exists to catch).
|
||||
"""
|
||||
import mcrfpy
|
||||
import sys
|
||||
import types
|
||||
|
||||
DYNAMIC = [
|
||||
"current_scene",
|
||||
"scenes",
|
||||
"timers",
|
||||
"animations",
|
||||
"default_transition",
|
||||
"default_transition_duration",
|
||||
"save_dir",
|
||||
]
|
||||
READONLY = ["scenes", "timers", "animations", "save_dir"]
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(label, condition):
|
||||
if condition:
|
||||
print(f" PASS: {label}")
|
||||
else:
|
||||
print(f" FAIL: {label}")
|
||||
failures.append(label)
|
||||
|
||||
|
||||
print("1. All dynamic attributes are in dir(mcrfpy)")
|
||||
names = dir(mcrfpy)
|
||||
for attr in DYNAMIC:
|
||||
check(f"'{attr}' in dir(mcrfpy)", attr in names)
|
||||
|
||||
print("2. dir() still contains normal module symbols")
|
||||
check("classes still listed", "Frame" in names and "Scene" in names)
|
||||
check("functions still listed", "find" in names and "step" in names)
|
||||
|
||||
print("3. They are real descriptors carrying docstrings")
|
||||
modtype = type(mcrfpy)
|
||||
for attr in DYNAMIC:
|
||||
descr = getattr(modtype, attr, None)
|
||||
check(f"'{attr}' is a getset descriptor",
|
||||
isinstance(descr, types.GetSetDescriptorType))
|
||||
check(f"'{attr}' has a docstring", bool(descr and descr.__doc__))
|
||||
|
||||
print("4. Access still works (behavior preserved)")
|
||||
scene = mcrfpy.Scene("issue356")
|
||||
mcrfpy.current_scene = scene
|
||||
check("current_scene round-trips", mcrfpy.current_scene is not None)
|
||||
check("scenes is a tuple", isinstance(mcrfpy.scenes, tuple))
|
||||
check("timers is a tuple", isinstance(mcrfpy.timers, tuple))
|
||||
check("animations is a tuple", isinstance(mcrfpy.animations, tuple))
|
||||
check("save_dir is a str", isinstance(mcrfpy.save_dir, str))
|
||||
check("default_transition_duration is a float",
|
||||
isinstance(mcrfpy.default_transition_duration, float))
|
||||
|
||||
mcrfpy.default_transition_duration = 0.25
|
||||
check("default_transition_duration is writable",
|
||||
abs(mcrfpy.default_transition_duration - 0.25) < 1e-6)
|
||||
|
||||
print("5. Read-only attributes still reject assignment")
|
||||
for attr in READONLY:
|
||||
try:
|
||||
setattr(mcrfpy, attr, ())
|
||||
check(f"assigning '{attr}' raises AttributeError", False)
|
||||
except AttributeError:
|
||||
check(f"assigning '{attr}' raises AttributeError", True)
|
||||
|
||||
print("6. Validation preserved on the writable ones")
|
||||
try:
|
||||
mcrfpy.default_transition_duration = -1.0
|
||||
check("negative duration raises ValueError", False)
|
||||
except ValueError:
|
||||
check("negative duration raises ValueError", True)
|
||||
|
||||
try:
|
||||
mcrfpy.default_transition_duration = "not a number"
|
||||
check("non-numeric duration raises TypeError", False)
|
||||
except TypeError:
|
||||
check("non-numeric duration raises TypeError", True)
|
||||
|
||||
print("7. Unknown attributes still raise AttributeError")
|
||||
try:
|
||||
mcrfpy.definitely_not_an_attribute
|
||||
check("unknown attribute raises AttributeError", False)
|
||||
except AttributeError:
|
||||
check("unknown attribute raises AttributeError", True)
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"FAIL - {len(failures)} check(s) failed:")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
@ -44,17 +44,14 @@ def shot():
|
|||
def case_child_parent_is_the_view():
|
||||
"""The child's parent is the Grid (UIGridView), not the internal _GridData.
|
||||
|
||||
Identity (`child.parent is grid`) is NOT asserted: `.parent` allocates a fresh
|
||||
wrapper on every read, so `is` fails for Frames too -- even `kid.parent is
|
||||
kid.parent` is False. That is a pre-existing engine-wide gap, not this bug, and
|
||||
it is filed separately. Here we assert the parent's TYPE and identify the view by
|
||||
name, which is what #364 actually turns on.
|
||||
Identity is asserted directly now that #369 routes .parent through the object
|
||||
cache; this originally had to settle for comparing .name.
|
||||
"""
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(200, 200), name="the_view")
|
||||
bubble = mcrfpy.Frame(pos=(32, 32), size=(20, 20))
|
||||
grid.children.append(bubble)
|
||||
|
||||
check("1a: child.parent is the grid view", bubble.parent.name == "the_view")
|
||||
check("1a: child.parent is the grid view", bubble.parent is grid)
|
||||
# #361: mcrfpy.Grid IS mcrfpy.GridView (one type object, two names), and the
|
||||
# canonical tp_name is GridView.
|
||||
check("1b: child.parent is a Grid, not a GridData",
|
||||
|
|
@ -72,7 +69,7 @@ def case_children_are_per_view_entities_are_shared():
|
|||
v1.children.append(marker)
|
||||
check("2b: child appended to v1 is in v1.children", len(v1.children) == 1)
|
||||
check("2c: child appended to v1 is NOT in v2.children", len(v2.children) == 0)
|
||||
check("2d: the child's parent is v1, not v2", marker.parent.name == "v1")
|
||||
check("2d: the child's parent is v1, not v2", marker.parent is v1)
|
||||
|
||||
v2.children.append(mcrfpy.Frame(pos=(64, 64), size=(20, 20)))
|
||||
check("2e: v2 keeps its own children", len(v2.children) == 1 and len(v1.children) == 1)
|
||||
|
|
|
|||
136
tests/regression/issue_369_parent_identity_test.py
Normal file
136
tests/regression/issue_369_parent_identity_test.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""
|
||||
Regression test for issue #369.
|
||||
|
||||
Every path that hands a C++ drawable back to Python must return the *same* wrapper
|
||||
object, or `child.parent is parent` silently answers False and Python subclasses
|
||||
reached through those paths lose their identity.
|
||||
|
||||
Broken paths this locks down:
|
||||
- UIDrawable::get_parent (.parent)
|
||||
- find_in_collection (mcrfpy.find / find_all)
|
||||
- UIEntityCollection concat/slice (entities + [...], entities[a:b])
|
||||
"""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(label, condition):
|
||||
if condition:
|
||||
print(f" PASS: {label}")
|
||||
else:
|
||||
print(f" FAIL: {label}")
|
||||
failures.append(label)
|
||||
|
||||
|
||||
scene = mcrfpy.Scene("issue369")
|
||||
ui = scene.children
|
||||
|
||||
# ---------------------------------------------------------------- .parent identity
|
||||
print("1. .parent returns a stable, identical wrapper")
|
||||
parent = mcrfpy.Frame(pos=(0, 0), size=(100, 100), name="parent_frame")
|
||||
kid = mcrfpy.Frame(pos=(1, 1), size=(10, 10), name="kid_frame")
|
||||
ui.append(parent)
|
||||
parent.children.append(kid)
|
||||
|
||||
check("kid.parent is parent", kid.parent is parent)
|
||||
check("repeated reads are identical", kid.parent is kid.parent)
|
||||
check("id() is stable across reads", id(kid.parent) == id(kid.parent))
|
||||
|
||||
# .parent must be usable as a dict key / set member
|
||||
seen = {kid.parent: "found"}
|
||||
check(".parent works as a dict key", seen.get(parent) == "found")
|
||||
check(".parent works in a set", len({kid.parent, kid.parent, parent}) == 1)
|
||||
|
||||
# ------------------------------------------------------- subclass survives .parent
|
||||
print("2. Python subclass identity survives .parent")
|
||||
|
||||
|
||||
class MyFrame(mcrfpy.Frame):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.custom_state = "preserved"
|
||||
|
||||
|
||||
sub_parent = MyFrame(pos=(0, 0), size=(200, 200), name="sub_parent")
|
||||
sub_kid = mcrfpy.Caption(pos=(5, 5), text="inner", name="sub_kid")
|
||||
ui.append(sub_parent)
|
||||
sub_parent.children.append(sub_kid)
|
||||
|
||||
check("sub_kid.parent is sub_parent", sub_kid.parent is sub_parent)
|
||||
check("subclass type preserved", isinstance(sub_kid.parent, MyFrame))
|
||||
check("subclass attributes preserved",
|
||||
getattr(sub_kid.parent, "custom_state", None) == "preserved")
|
||||
|
||||
# ------------------------------------------------- .parent covers all drawable types
|
||||
print("3. .parent resolves non-Frame/Caption/Sprite/Grid parents (was None)")
|
||||
# Line/Circle/Arc/Viewport3D had no switch arm and fell through to Py_RETURN_NONE.
|
||||
# They aren't containers, so verify the *child-of-grid* case instead: a GridView
|
||||
# parent must come back identical, and every type must at least round-trip via find().
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(100, 100), name="the_grid")
|
||||
ui.append(grid)
|
||||
overlay = mcrfpy.Frame(pos=(0, 0), size=(10, 10), name="overlay_child")
|
||||
grid.children.append(overlay)
|
||||
check("overlay.parent is grid", overlay.parent is grid)
|
||||
|
||||
# ------------------------------------------------------------- find() identity
|
||||
print("4. find() / find_all() return existing wrappers")
|
||||
found = mcrfpy.find("parent_frame", "issue369")
|
||||
check("find() returns the same object", found is parent)
|
||||
|
||||
found_sub = mcrfpy.find("sub_parent", "issue369")
|
||||
check("find() preserves subclass", found_sub is sub_parent)
|
||||
check("find() subclass attrs intact",
|
||||
getattr(found_sub, "custom_state", None) == "preserved")
|
||||
|
||||
all_found = mcrfpy.find_all("*_frame", "issue369")
|
||||
by_id = {id(o) for o in all_found}
|
||||
check("find_all() returns live wrappers",
|
||||
id(parent) in by_id and id(kid) in by_id)
|
||||
|
||||
# ---------------------------------------------------------- entity collection paths
|
||||
print("5. EntityCollection slice / concat preserve entity identity")
|
||||
|
||||
|
||||
class MyEntity(mcrfpy.Entity):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.tag = "mine"
|
||||
|
||||
|
||||
e0 = MyEntity(grid_pos=(1, 1))
|
||||
e1 = mcrfpy.Entity(grid_pos=(2, 2))
|
||||
e2 = mcrfpy.Entity(grid_pos=(3, 3))
|
||||
for e in (e0, e1, e2):
|
||||
grid.entities.append(e)
|
||||
|
||||
check("entities[0] is e0", grid.entities[0] is e0)
|
||||
|
||||
sliced = grid.entities[0:2]
|
||||
check("slice returns the same wrappers", sliced[0] is e0 and sliced[1] is e1)
|
||||
check("slice preserves subclass", isinstance(sliced[0], MyEntity))
|
||||
check("slice preserves subclass attrs", getattr(sliced[0], "tag", None) == "mine")
|
||||
|
||||
concat = grid.entities + []
|
||||
check("concat returns the same wrappers", concat[0] is e0)
|
||||
check("concat preserves subclass", isinstance(concat[0], MyEntity))
|
||||
|
||||
# The #266 identity ref must survive a duplicate-wrapper round trip: before the fix,
|
||||
# the temporary wrapper's dealloc cleared UIEntity::pyobject on the shared C++ object.
|
||||
del sliced, concat
|
||||
import gc
|
||||
gc.collect()
|
||||
check("entity identity survives temp wrappers", grid.entities[0] is e0)
|
||||
check("subclass attrs survive temp wrappers",
|
||||
getattr(grid.entities[0], "tag", None) == "mine")
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"FAIL - {len(failures)} check(s) failed:")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
198
tests/regression/issue_373_subclass_identity_survives_gc_test.py
Normal file
198
tests/regression/issue_373_subclass_identity_survives_gc_test.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Issue #373: a GC'd Python subclass wrapper must not resurrect as its base type.
|
||||
|
||||
PythonObjectCache holds only weakrefs, so #369 (route every C++ -> Python conversion
|
||||
through the cache) preserved object identity only while Python still held a reference
|
||||
to the wrapper. If the user dropped their last reference while C++ still owned the
|
||||
object, the wrapper was collected, the next lookup missed the cache, and a fresh
|
||||
BASE-type wrapper was allocated -- silently downgrading the subclass and losing every
|
||||
attribute set on it.
|
||||
|
||||
The fix is an owner-held strong ref ("pin"): a subclassed drawable's wrapper is kept
|
||||
alive for exactly as long as the drawable is a member of a children collection. That
|
||||
boundary is the parent link, so the pin is taken in setParent()/setParentScene() and
|
||||
released on every path out -- including an owner destroyed while it still holds
|
||||
children, which is the leak this test also guards.
|
||||
"""
|
||||
|
||||
import gc
|
||||
import sys
|
||||
import weakref
|
||||
|
||||
import mcrfpy
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
if not cond:
|
||||
failures.append(msg)
|
||||
print(f"FAIL: {msg}")
|
||||
else:
|
||||
print(f" ok: {msg}")
|
||||
|
||||
|
||||
class MyFrame(mcrfpy.Frame):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.hp = 100
|
||||
|
||||
|
||||
class MyCaption(mcrfpy.Caption):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.tag = "captioned"
|
||||
|
||||
|
||||
def test_parent_survives_gc():
|
||||
"""The scenario from the issue: reach a GC'd subclass parent through .parent."""
|
||||
scene = mcrfpy.Scene("gc_parent")
|
||||
|
||||
parent = MyFrame(pos=(0, 0), size=(100, 100))
|
||||
scene.children.append(parent)
|
||||
kid = mcrfpy.Frame(pos=(1, 1), size=(10, 10))
|
||||
parent.children.append(kid)
|
||||
|
||||
del parent # C++ still owns it via the scene's collection
|
||||
gc.collect()
|
||||
|
||||
recovered = kid.parent
|
||||
check(type(recovered) is MyFrame,
|
||||
f"kid.parent is still a MyFrame after gc (got {type(recovered).__name__})")
|
||||
check(getattr(recovered, "hp", None) == 100,
|
||||
"the subclass attribute set in __init__ survived")
|
||||
check(kid.parent is kid.parent,
|
||||
"repeated .parent reads return the same object")
|
||||
check(kid.parent is recovered,
|
||||
"the pinned wrapper is the one handed back, not a new one")
|
||||
|
||||
|
||||
def test_collection_indexing_survives_gc():
|
||||
"""Reaching the same object by indexing the collection, not via .parent."""
|
||||
scene = mcrfpy.Scene("gc_index")
|
||||
sub = MyCaption(text="hello", pos=(5, 5))
|
||||
scene.children.append(sub)
|
||||
|
||||
ref = weakref.ref(sub)
|
||||
del sub
|
||||
gc.collect()
|
||||
|
||||
check(ref() is not None,
|
||||
"a subclassed drawable in a scene keeps its wrapper alive")
|
||||
got = scene.children[0]
|
||||
check(type(got) is MyCaption,
|
||||
f"scene.children[0] is still a MyCaption (got {type(got).__name__})")
|
||||
check(got.tag == "captioned", "its subclass attribute survived")
|
||||
check(got is ref(), "it is the original wrapper, not a copy")
|
||||
|
||||
|
||||
def test_pin_released_on_removal():
|
||||
"""Leaving the collection must drop the pin, or every subclass leaks forever."""
|
||||
scene = mcrfpy.Scene("gc_release")
|
||||
sub = MyFrame(pos=(0, 0), size=(10, 10))
|
||||
scene.children.append(sub)
|
||||
|
||||
ref = weakref.ref(sub)
|
||||
scene.children.remove(sub)
|
||||
del sub
|
||||
gc.collect()
|
||||
|
||||
check(ref() is None,
|
||||
"the wrapper is freed once the drawable leaves the collection")
|
||||
|
||||
|
||||
def test_pin_released_when_owner_dies():
|
||||
"""An owner destroyed while still holding children must drop their pins.
|
||||
|
||||
Nothing calls setParent(nullptr) on this path -- the children vector simply dies --
|
||||
so without an explicit release in ~UIFrame the wrapper <-> drawable cycle would
|
||||
strand the whole subtree in memory.
|
||||
"""
|
||||
scene = mcrfpy.Scene("gc_owner")
|
||||
holder = mcrfpy.Frame(pos=(0, 0), size=(50, 50))
|
||||
scene.children.append(holder)
|
||||
|
||||
inner = MyFrame(pos=(1, 1), size=(5, 5))
|
||||
holder.children.append(inner)
|
||||
ref = weakref.ref(inner)
|
||||
del inner
|
||||
gc.collect()
|
||||
check(ref() is not None, "a nested subclass is pinned while its owner holds it")
|
||||
|
||||
scene.children.remove(holder)
|
||||
del holder
|
||||
gc.collect()
|
||||
check(ref() is None,
|
||||
"the nested wrapper is freed when its owner is destroyed (no leak)")
|
||||
|
||||
|
||||
def test_reparenting_preserves_identity():
|
||||
"""Moving between collections must not drop the subclass on the floor.
|
||||
|
||||
Reparenting unlinks then relinks; if the unlink released the last reference, the
|
||||
re-pin would find nothing in the cache and the subclass would be gone.
|
||||
"""
|
||||
scene = mcrfpy.Scene("gc_reparent")
|
||||
a = mcrfpy.Frame(pos=(0, 0), size=(80, 80))
|
||||
b = mcrfpy.Frame(pos=(0, 0), size=(80, 80))
|
||||
scene.children.append(a)
|
||||
scene.children.append(b)
|
||||
|
||||
sub = MyFrame(pos=(2, 2), size=(8, 8))
|
||||
sub.hp = 55
|
||||
a.children.append(sub)
|
||||
ref = weakref.ref(sub)
|
||||
del sub
|
||||
gc.collect()
|
||||
|
||||
moved = a.children[0]
|
||||
b.children.append(moved) # reparent a -> b
|
||||
del moved
|
||||
gc.collect()
|
||||
|
||||
check(len(a.children) == 0, "the drawable left its old parent")
|
||||
check(len(b.children) == 1, "the drawable joined its new parent")
|
||||
check(ref() is not None, "it is still pinned by its new owner")
|
||||
got = b.children[0]
|
||||
check(type(got) is MyFrame,
|
||||
f"it is still a MyFrame after reparenting (got {type(got).__name__})")
|
||||
check(got.hp == 55, "and kept the attribute that was set on it")
|
||||
|
||||
|
||||
def test_base_type_is_not_pinned():
|
||||
"""Only subclasses are pinned. A base wrapper carries no state, so re-creating it
|
||||
is unobservable -- and pinning it would cost memory for nothing."""
|
||||
scene = mcrfpy.Scene("gc_base")
|
||||
plain = mcrfpy.Frame(pos=(0, 0), size=(10, 10))
|
||||
scene.children.append(plain)
|
||||
|
||||
ref = weakref.ref(plain)
|
||||
del plain
|
||||
gc.collect()
|
||||
|
||||
check(ref() is None, "a base-type wrapper is not pinned")
|
||||
got = scene.children[0]
|
||||
check(type(got) is mcrfpy.Frame, "it still round-trips as a Frame")
|
||||
check(got.w == 10, "and its C++ state is intact")
|
||||
|
||||
|
||||
def main():
|
||||
test_parent_survives_gc()
|
||||
test_collection_indexing_survives_gc()
|
||||
test_pin_released_on_removal()
|
||||
test_pin_released_when_owner_dies()
|
||||
test_reparenting_preserves_identity()
|
||||
test_base_type_is_not_pinned()
|
||||
|
||||
if failures:
|
||||
print(f"\nFAILED ({len(failures)} checks)")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("\nPASS")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
241
tests/regression/issue_377_collection_parent_link_test.py
Normal file
241
tests/regression/issue_377_collection_parent_link_test.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Issue #377: every UICollection mutator must maintain the parent link.
|
||||
|
||||
`x in collection` and `x.parent is <collection owner>` are supposed to be two views of
|
||||
the same fact (#122/#183). Only append() maintained it. The rest got it wrong two ways:
|
||||
|
||||
* insert/extend/setitem called setParent(owner.lock()) unconditionally. A SCENE
|
||||
collection has no owner drawable, so that resolved to null -- and setParent(nullptr)
|
||||
also clears parent_scene. The drawable landed in the scene's UI vector, rendered
|
||||
fine, and reported .parent of None.
|
||||
|
||||
* The slice arms never touched the link at all: a slice-delete left the removed child
|
||||
pointing at a parent that no longer contained it, and a slice-assign never parented
|
||||
the incoming items nor unparented the ones they displaced.
|
||||
|
||||
Two adjacent traps in the same functions are also covered here: `collection[-1]` on an
|
||||
EMPTY collection hung the engine forever (`while (index < 0) index += size()` adds zero
|
||||
each pass), and getitem's `index > size() - 1` bounds check underflowed to SIZE_MAX when
|
||||
size() was 0, so it went on to index an empty vector.
|
||||
|
||||
This invariant is what #373's identity pin hangs off, so it has to hold for every path.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import mcrfpy
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
if not cond:
|
||||
failures.append(msg)
|
||||
print(f"FAIL: {msg}")
|
||||
else:
|
||||
print(f" ok: {msg}")
|
||||
|
||||
|
||||
def frame(n=10):
|
||||
return mcrfpy.Frame(pos=(0, 0), size=(n, n))
|
||||
|
||||
|
||||
def check_linked(child, owner, collection, label):
|
||||
"""The two views of membership must agree."""
|
||||
check(child in collection, f"{label}: child is in the collection")
|
||||
check(child.parent is owner, f"{label}: child.parent is the owner (got {child.parent!r})")
|
||||
|
||||
|
||||
def check_unlinked(child, collection, label):
|
||||
check(child not in collection, f"{label}: child is NOT in the collection")
|
||||
check(child.parent is None, f"{label}: child.parent is None (got {child.parent!r})")
|
||||
|
||||
|
||||
def test_scene_collection_mutators():
|
||||
"""A scene collection parents via parent_scene; only append used to do it."""
|
||||
scene = mcrfpy.Scene("link_scene")
|
||||
|
||||
a, b, c, d = frame(), frame(), frame(), frame()
|
||||
|
||||
scene.children.append(a)
|
||||
check_linked(a, scene, scene.children, "scene append")
|
||||
|
||||
scene.children.insert(0, b)
|
||||
check_linked(b, scene, scene.children, "scene insert")
|
||||
|
||||
scene.children.extend([c])
|
||||
check_linked(c, scene, scene.children, "scene extend")
|
||||
|
||||
scene.children[0] = d
|
||||
check_linked(d, scene, scene.children, "scene setitem")
|
||||
|
||||
|
||||
def test_frame_collection_mutators():
|
||||
scene = mcrfpy.Scene("link_frame")
|
||||
owner = mcrfpy.Frame(pos=(0, 0), size=(200, 200))
|
||||
scene.children.append(owner)
|
||||
|
||||
a, b, c, d = frame(), frame(), frame(), frame()
|
||||
|
||||
owner.children.append(a)
|
||||
check_linked(a, owner, owner.children, "frame append")
|
||||
|
||||
owner.children.insert(0, b)
|
||||
check_linked(b, owner, owner.children, "frame insert")
|
||||
|
||||
owner.children.extend([c])
|
||||
check_linked(c, owner, owner.children, "frame extend")
|
||||
|
||||
replaced = owner.children[0]
|
||||
owner.children[0] = d
|
||||
check_linked(d, owner, owner.children, "frame setitem")
|
||||
check_unlinked(replaced, owner.children, "frame setitem (displaced element)")
|
||||
|
||||
|
||||
def test_removal_mutators_unlink():
|
||||
scene = mcrfpy.Scene("link_remove")
|
||||
owner = mcrfpy.Frame(pos=(0, 0), size=(200, 200))
|
||||
scene.children.append(owner)
|
||||
|
||||
a, b, c = frame(), frame(), frame()
|
||||
owner.children.extend([a, b, c])
|
||||
|
||||
owner.children.remove(a)
|
||||
check_unlinked(a, owner.children, "frame remove()")
|
||||
|
||||
popped = owner.children.pop(0)
|
||||
check_unlinked(popped, owner.children, "frame pop()")
|
||||
|
||||
del owner.children[0]
|
||||
check_unlinked(c, owner.children, "frame __delitem__")
|
||||
check(len(owner.children) == 0, "the collection is empty after removing everything")
|
||||
|
||||
|
||||
def test_slice_delete_unlinks():
|
||||
scene = mcrfpy.Scene("link_slicedel")
|
||||
owner = mcrfpy.Frame(pos=(0, 0), size=(200, 200))
|
||||
scene.children.append(owner)
|
||||
|
||||
a, b, c, d = frame(), frame(), frame(), frame()
|
||||
owner.children.extend([a, b, c, d])
|
||||
|
||||
del owner.children[0:2] # contiguous
|
||||
check(len(owner.children) == 2, "contiguous slice-delete removed two elements")
|
||||
check_unlinked(a, owner.children, "contiguous slice-delete (a)")
|
||||
check_unlinked(b, owner.children, "contiguous slice-delete (b)")
|
||||
check_linked(c, owner, owner.children, "contiguous slice-delete (survivor c)")
|
||||
|
||||
e, f = frame(), frame()
|
||||
owner.children.extend([e, f]) # now: c, d, e, f
|
||||
del owner.children[0::2] # extended slice: removes c and e
|
||||
check_unlinked(c, owner.children, "extended slice-delete (c)")
|
||||
check_unlinked(e, owner.children, "extended slice-delete (e)")
|
||||
check_linked(d, owner, owner.children, "extended slice-delete (survivor d)")
|
||||
check_linked(f, owner, owner.children, "extended slice-delete (survivor f)")
|
||||
|
||||
|
||||
def test_slice_assign_links():
|
||||
scene = mcrfpy.Scene("link_sliceassign")
|
||||
owner = mcrfpy.Frame(pos=(0, 0), size=(200, 200))
|
||||
scene.children.append(owner)
|
||||
|
||||
a, b = frame(), frame()
|
||||
owner.children.extend([a, b])
|
||||
|
||||
c, d = frame(), frame()
|
||||
owner.children[0:1] = [c, d] # resizing contiguous assign
|
||||
check_linked(c, owner, owner.children, "slice-assign (new element c)")
|
||||
check_linked(d, owner, owner.children, "slice-assign (new element d)")
|
||||
check_unlinked(a, owner.children, "slice-assign (displaced element a)")
|
||||
check_linked(b, owner, owner.children, "slice-assign (untouched element b)")
|
||||
|
||||
e = frame()
|
||||
owner.children[0:1] = [e] # same-size contiguous assign
|
||||
check_linked(e, owner, owner.children, "same-size slice-assign (new element)")
|
||||
check_unlinked(c, owner.children, "same-size slice-assign (displaced element)")
|
||||
|
||||
# Extended slice assign
|
||||
g, h = frame(), frame()
|
||||
owner.children[0::2] = [g, h]
|
||||
check_linked(g, owner, owner.children, "extended slice-assign (g)")
|
||||
check_linked(h, owner, owner.children, "extended slice-assign (h)")
|
||||
|
||||
|
||||
def test_slice_assign_on_scene():
|
||||
"""Slice-assign into a scene collection must parent to the SCENE."""
|
||||
scene = mcrfpy.Scene("link_slicescene")
|
||||
a, b = frame(), frame()
|
||||
scene.children.extend([a, b])
|
||||
|
||||
c = frame()
|
||||
scene.children[0:1] = [c]
|
||||
check_linked(c, scene, scene.children, "scene slice-assign")
|
||||
check_unlinked(a, scene.children, "scene slice-assign (displaced)")
|
||||
|
||||
|
||||
def test_reparent_between_owners():
|
||||
"""Appending to a new owner must remove from the old one, not leave it in both."""
|
||||
scene = mcrfpy.Scene("link_reparent")
|
||||
one = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
|
||||
two = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
|
||||
scene.children.extend([one, two])
|
||||
|
||||
x = frame()
|
||||
one.children.append(x)
|
||||
check_linked(x, one, one.children, "child starts in owner one")
|
||||
|
||||
two.children.append(x)
|
||||
check(x not in one.children, "reparenting removed the child from its old owner")
|
||||
check_linked(x, two, two.children, "reparenting linked the child to its new owner")
|
||||
|
||||
|
||||
def test_empty_collection_negative_index():
|
||||
"""`collection[-1]` on an empty collection used to hang the engine forever."""
|
||||
scene = mcrfpy.Scene("link_empty")
|
||||
|
||||
try:
|
||||
scene.children[-1]
|
||||
check(False, "getitem[-1] on an empty collection raises IndexError")
|
||||
except IndexError:
|
||||
check(True, "getitem[-1] on an empty collection raises IndexError")
|
||||
|
||||
try:
|
||||
scene.children[-1] = frame()
|
||||
check(False, "setitem[-1] on an empty collection raises IndexError")
|
||||
except IndexError:
|
||||
check(True, "setitem[-1] on an empty collection raises IndexError")
|
||||
|
||||
# Negative indexing still works when there IS something to index.
|
||||
a, b = frame(), frame()
|
||||
scene.children.extend([a, b])
|
||||
check(scene.children[-1] is b, "negative indexing resolves from the end")
|
||||
check(scene.children[-2] is a, "negative indexing reaches the first element")
|
||||
try:
|
||||
scene.children[-3]
|
||||
check(False, "an out-of-range negative index raises IndexError")
|
||||
except IndexError:
|
||||
check(True, "an out-of-range negative index raises IndexError")
|
||||
|
||||
|
||||
def main():
|
||||
test_scene_collection_mutators()
|
||||
test_frame_collection_mutators()
|
||||
test_removal_mutators_unlink()
|
||||
test_slice_delete_unlinks()
|
||||
test_slice_assign_links()
|
||||
test_slice_assign_on_scene()
|
||||
test_reparent_between_owners()
|
||||
test_empty_collection_negative_index()
|
||||
|
||||
if failures:
|
||||
print(f"\nFAILED ({len(failures)} checks)")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("\nPASS")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,220 +1,246 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Comprehensive test for Issue #9: Recreate RenderTexture when UIGrid is resized
|
||||
Regression test for Issue #9: Recreate RenderTexture when a grid view is resized
|
||||
|
||||
This test demonstrates that UIGrid has a hardcoded RenderTexture size of 1920x1080,
|
||||
which causes rendering issues when the grid is resized beyond these dimensions.
|
||||
The bug: UIGrid::render() created its RenderTexture once, at a hardcoded size, and
|
||||
never recreated it. Resizing the grid widget beyond that texture left the new area
|
||||
unrendered (clipped), and shrinking it left stale content blitted outside the box.
|
||||
|
||||
The bug: UIGrid::render() creates a RenderTexture with fixed size (1920x1080) once,
|
||||
but never recreates it when the grid is resized, causing clipping and rendering artifacts.
|
||||
The fix (UIGridView::ensureRenderTextureSize()) sizes the RenderTexture from the game
|
||||
resolution and recreates it whenever that changes; the blit is clipped to the widget
|
||||
box. So today a grid must render *correctly across its whole box at every size*:
|
||||
|
||||
* enlarging the widget renders content in the newly exposed area (was clipped),
|
||||
* shrinking it leaves no stale pixels outside the new box,
|
||||
* content never spills past the widget bounds,
|
||||
* a widget larger than the window still renders its visible portion.
|
||||
|
||||
This test asserts those four properties on real screenshot pixels. The old version of
|
||||
this file only printed "check the screenshots by eye" and, worse, died in setup on
|
||||
grid.at(x, y).color (removed -- per-cell color lives on a ColorLayer now), so none of
|
||||
it ever ran.
|
||||
|
||||
API notes for the update: GridPoint has no .color -> mcrfpy.ColorLayer; Grid takes
|
||||
grid_size=/pos=/size= kwargs; mcrfpy.setScene/sceneUI -> mcrfpy.Scene + scene.children;
|
||||
step() is the clock and never renders, automation.screenshot() forces the render.
|
||||
Window.resolution cannot change in headless, so the resolution-driven recreation path
|
||||
is exercised via widget resizes only.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
import struct
|
||||
import sys
|
||||
import os
|
||||
import zlib
|
||||
|
||||
def create_checkerboard_pattern(grid, grid_width, grid_height, cell_size=2):
|
||||
"""Create a checkerboard pattern on the grid for visibility"""
|
||||
FAILURES = []
|
||||
|
||||
# Grid data is large enough (160 x 160 cells) that its content covers every widget
|
||||
# size used below, so "no content here" always means a rendering failure.
|
||||
GRID_CELLS = 160
|
||||
|
||||
WHITE = (255, 255, 255)
|
||||
GRAY = (100, 100, 100)
|
||||
RED = (255, 0, 0)
|
||||
BACKGROUND = (0, 0, 0) # scene clear color, outside any grid
|
||||
CONTENT_COLORS = (WHITE, GRAY, RED)
|
||||
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
if condition:
|
||||
print(" PASS: %s" % label)
|
||||
else:
|
||||
print(" FAIL: %s %s" % (label, detail))
|
||||
FAILURES.append(label)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Minimal PNG reader (stdlib only), same as tests/unit/validate_screenshot_test.py
|
||||
# --------------------------------------------------------------------------- #
|
||||
def read_png(path):
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
assert data[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG"
|
||||
pos = 8
|
||||
idat = b""
|
||||
width = height = depth = color = None
|
||||
while pos < len(data):
|
||||
(length,) = struct.unpack(">I", data[pos:pos + 4])
|
||||
ctype = data[pos + 4:pos + 8]
|
||||
chunk = data[pos + 8:pos + 8 + length]
|
||||
pos += 12 + length
|
||||
if ctype == b"IHDR":
|
||||
width, height, depth, color, _, _, interlace = struct.unpack(">IIBBBBB", chunk)
|
||||
assert depth == 8, "unexpected bit depth %d" % depth
|
||||
assert color in (2, 6), "unexpected color type %d" % color
|
||||
assert interlace == 0, "interlaced PNG not supported"
|
||||
elif ctype == b"IDAT":
|
||||
idat += chunk
|
||||
elif ctype == b"IEND":
|
||||
break
|
||||
|
||||
raw = zlib.decompress(idat)
|
||||
channels = 3 if color == 2 else 4
|
||||
stride = width * channels
|
||||
out = bytearray(height * stride)
|
||||
prev = bytearray(stride)
|
||||
p = 0
|
||||
for y in range(height):
|
||||
filt = raw[p]
|
||||
p += 1
|
||||
line = bytearray(raw[p:p + stride])
|
||||
p += stride
|
||||
if filt == 1: # Sub
|
||||
for i in range(channels, stride):
|
||||
line[i] = (line[i] + line[i - channels]) & 0xFF
|
||||
elif filt == 2: # Up
|
||||
for i in range(stride):
|
||||
line[i] = (line[i] + prev[i]) & 0xFF
|
||||
elif filt == 3: # Average
|
||||
for i in range(stride):
|
||||
a = line[i - channels] if i >= channels else 0
|
||||
line[i] = (line[i] + ((a + prev[i]) >> 1)) & 0xFF
|
||||
elif filt == 4: # Paeth
|
||||
for i in range(stride):
|
||||
a = line[i - channels] if i >= channels else 0
|
||||
b = prev[i]
|
||||
c = prev[i - channels] if i >= channels else 0
|
||||
pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c)
|
||||
pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
|
||||
line[i] = (line[i] + pr) & 0xFF
|
||||
elif filt != 0:
|
||||
raise AssertionError("bad PNG filter %d" % filt)
|
||||
out[y * stride:(y + 1) * stride] = line
|
||||
prev = line
|
||||
return width, height, channels, bytes(out)
|
||||
|
||||
|
||||
class Image:
|
||||
def __init__(self, path):
|
||||
self.w, self.h, self.ch, self.px = read_png(path)
|
||||
|
||||
def at(self, x, y):
|
||||
i = (y * self.w + x) * self.ch
|
||||
return tuple(self.px[i:i + 3])
|
||||
|
||||
|
||||
def create_checkerboard_pattern(layer, grid_width, grid_height, cell_size=2):
|
||||
"""Create a checkerboard pattern on the color layer for visibility"""
|
||||
for x in range(grid_width):
|
||||
for y in range(grid_height):
|
||||
if (x // cell_size + y // cell_size) % 2 == 0:
|
||||
grid.at(x, y).color = mcrfpy.Color(255, 255, 255, 255) # White
|
||||
layer.set((x, y), mcrfpy.Color(*WHITE))
|
||||
else:
|
||||
grid.at(x, y).color = mcrfpy.Color(100, 100, 100, 255) # Gray
|
||||
layer.set((x, y), mcrfpy.Color(*GRAY))
|
||||
|
||||
def add_border_markers(grid, grid_width, grid_height):
|
||||
|
||||
def add_border_markers(layer, grid_width, grid_height):
|
||||
"""Add colored markers at the borders to test rendering limits"""
|
||||
# Red border on top
|
||||
for x in range(grid_width):
|
||||
grid.at(x, 0).color = mcrfpy.Color(255, 0, 0, 255)
|
||||
|
||||
# Green border on right
|
||||
layer.set((x, 0), mcrfpy.Color(*RED))
|
||||
layer.set((x, grid_height - 1), mcrfpy.Color(*RED))
|
||||
for y in range(grid_height):
|
||||
grid.at(grid_width-1, y).color = mcrfpy.Color(0, 255, 0, 255)
|
||||
layer.set((0, y), mcrfpy.Color(*RED))
|
||||
layer.set((grid_width - 1, y), mcrfpy.Color(*RED))
|
||||
|
||||
# Blue border on bottom
|
||||
for x in range(grid_width):
|
||||
grid.at(x, grid_height-1).color = mcrfpy.Color(0, 0, 255, 255)
|
||||
|
||||
# Yellow border on left
|
||||
for y in range(grid_height):
|
||||
grid.at(0, y).color = mcrfpy.Color(255, 255, 0, 255)
|
||||
def render(path):
|
||||
"""step() advances the sim; the screenshot is what forces a render."""
|
||||
mcrfpy.step(0.01)
|
||||
automation.screenshot(path)
|
||||
return Image(path)
|
||||
|
||||
|
||||
def anchor_camera(grid):
|
||||
"""Keep tile (0,0) at the widget's top-left after a resize (#169 default)."""
|
||||
grid.center = (grid.w / 2.0, grid.h / 2.0)
|
||||
|
||||
|
||||
def is_content(px):
|
||||
return px in CONTENT_COLORS
|
||||
|
||||
|
||||
print("=== Testing grid RenderTexture resize (Issue #9) ===\n")
|
||||
|
||||
# Set up the test scene
|
||||
test = mcrfpy.Scene("test")
|
||||
mcrfpy.current_scene = test
|
||||
|
||||
print("=== Testing UIGrid RenderTexture Resize (Issue #9) ===\n")
|
||||
|
||||
scene_ui = test.children
|
||||
|
||||
# Test 1: Small grid (should work fine)
|
||||
print("--- Test 1: Small Grid (400x300) ---")
|
||||
grid1 = mcrfpy.Grid(20, 15) # 20x15 tiles
|
||||
grid1.x = 10
|
||||
grid1.y = 10
|
||||
grid1.w = 400
|
||||
grid1.h = 300
|
||||
scene_ui.append(grid1)
|
||||
GRID_X, GRID_Y = 10, 10
|
||||
grid = mcrfpy.Grid(grid_size=(GRID_CELLS, GRID_CELLS), pos=(GRID_X, GRID_Y), size=(200, 150))
|
||||
colors = mcrfpy.ColorLayer(name="cells")
|
||||
grid.add_layer(colors)
|
||||
create_checkerboard_pattern(colors, GRID_CELLS, GRID_CELLS)
|
||||
add_border_markers(colors, GRID_CELLS, GRID_CELLS)
|
||||
scene_ui.append(grid)
|
||||
|
||||
create_checkerboard_pattern(grid1, 20, 15)
|
||||
add_border_markers(grid1, 20, 15)
|
||||
# --- Test 1: small grid renders its whole box, and nothing outside it ---------
|
||||
print("--- Test 1: Small Grid (200x150) ---")
|
||||
anchor_camera(grid)
|
||||
img = render("/tmp/issue_9_small_grid.png")
|
||||
|
||||
mcrfpy.step(0.01)
|
||||
automation.screenshot("/tmp/issue_9_small_grid.png")
|
||||
print("PASS: Small grid created and rendered")
|
||||
check("top-left tile of small grid is the red border marker",
|
||||
img.at(GRID_X + 2, GRID_Y + 2) == RED,
|
||||
"got %s" % (img.at(GRID_X + 2, GRID_Y + 2),))
|
||||
check("interior of small grid is rendered content",
|
||||
is_content(img.at(100, 100)), "got %s" % (img.at(100, 100),))
|
||||
check("far corner of small grid box is rendered content",
|
||||
is_content(img.at(GRID_X + 195, GRID_Y + 145)),
|
||||
"got %s" % (img.at(GRID_X + 195, GRID_Y + 145),))
|
||||
check("nothing rendered outside the small grid box",
|
||||
img.at(500, 400) == BACKGROUND, "got %s" % (img.at(500, 400),))
|
||||
|
||||
# Test 2: Medium grid at 1920x1080 limit
|
||||
print("\n--- Test 2: Medium Grid at 1920x1080 Limit ---")
|
||||
grid2 = mcrfpy.Grid(64, 36) # 64x36 tiles at 30px each = 1920x1080
|
||||
grid2.x = 10
|
||||
grid2.y = 320
|
||||
grid2.w = 1920
|
||||
grid2.h = 1080
|
||||
scene_ui.append(grid2)
|
||||
# --- Test 2: enlarging the widget must render the newly exposed area ----------
|
||||
# This is the heart of #9: (500,400) and (880,690) were outside the previous render
|
||||
# area. With a stale RenderTexture they stay blank/clipped.
|
||||
print("\n--- Test 2: Resize 200x150 -> 900x700 ---")
|
||||
grid.w = 900
|
||||
grid.h = 700
|
||||
anchor_camera(grid)
|
||||
img = render("/tmp/issue_9_resized.png")
|
||||
|
||||
create_checkerboard_pattern(grid2, 64, 36, 4)
|
||||
add_border_markers(grid2, 64, 36)
|
||||
check("area exposed by the resize is rendered (500,400)",
|
||||
is_content(img.at(500, 400)), "got %s" % (img.at(500, 400),))
|
||||
check("far corner of the enlarged box is rendered (880,690)",
|
||||
is_content(img.at(880, 690)), "got %s" % (img.at(880, 690),))
|
||||
check("enlarged grid does not spill past its right edge",
|
||||
img.at(GRID_X + 900 + 5, 100) == BACKGROUND,
|
||||
"got %s" % (img.at(GRID_X + 900 + 5, 100),))
|
||||
check("enlarged grid does not spill past its bottom edge",
|
||||
img.at(100, GRID_Y + 700 + 5) == BACKGROUND,
|
||||
"got %s" % (img.at(100, GRID_Y + 700 + 5),))
|
||||
|
||||
mcrfpy.step(0.01)
|
||||
automation.screenshot("/tmp/issue_9_limit_grid.png")
|
||||
print("PASS: Grid at RenderTexture limit created")
|
||||
# --- Test 3: widget larger than the window still renders its visible portion --
|
||||
print("\n--- Test 3: Grid larger than the window (2400x1400) ---")
|
||||
grid.w = 2400
|
||||
grid.h = 1400
|
||||
anchor_camera(grid)
|
||||
img = render("/tmp/issue_9_beyond_window.png")
|
||||
|
||||
# Test 3: Resize grid1 beyond limits
|
||||
print("\n--- Test 3: Resizing Small Grid Beyond 1920x1080 ---")
|
||||
print("Original size: 400x300")
|
||||
grid1.w = 2400
|
||||
grid1.h = 1400
|
||||
print(f"Resized to: {grid1.w}x{grid1.h}")
|
||||
check("oversized grid renders at the far edge of the window (1000,700)",
|
||||
is_content(img.at(1000, 700)), "got %s" % (img.at(1000, 700),))
|
||||
check("oversized grid renders near the window origin (20,20)",
|
||||
is_content(img.at(20, 20)), "got %s" % (img.at(20, 20),))
|
||||
|
||||
# The content should still be visible but may be clipped
|
||||
mcrfpy.step(0.01)
|
||||
automation.screenshot("/tmp/issue_9_resized_beyond_limit.png")
|
||||
print("EXPECTED ISSUE: Grid resized beyond RenderTexture limits")
|
||||
print(" Content beyond 1920x1080 will be clipped!")
|
||||
# --- Test 4: shrinking must not leave stale pixels outside the new box --------
|
||||
print("\n--- Test 4: Shrink back to 200x150 ---")
|
||||
grid.w = 200
|
||||
grid.h = 150
|
||||
anchor_camera(grid)
|
||||
img = render("/tmp/issue_9_shrunk.png")
|
||||
|
||||
# Test 4: Create large grid from start
|
||||
print("\n--- Test 4: Large Grid from Start (2400x1400) ---")
|
||||
# Clear previous grids
|
||||
while len(scene_ui) > 0:
|
||||
scene_ui.remove(0)
|
||||
check("shrunken grid still renders inside its box",
|
||||
is_content(img.at(100, 100)), "got %s" % (img.at(100, 100),))
|
||||
check("no stale content left outside the shrunken box (500,400)",
|
||||
img.at(500, 400) == BACKGROUND, "got %s" % (img.at(500, 400),))
|
||||
check("no stale content left outside the shrunken box (880,690)",
|
||||
img.at(880, 690) == BACKGROUND, "got %s" % (img.at(880, 690),))
|
||||
|
||||
grid3 = mcrfpy.Grid(80, 50) # Large tile count
|
||||
grid3.x = 10
|
||||
grid3.y = 10
|
||||
grid3.w = 2400
|
||||
grid3.h = 1400
|
||||
scene_ui.append(grid3)
|
||||
|
||||
create_checkerboard_pattern(grid3, 80, 50, 5)
|
||||
add_border_markers(grid3, 80, 50)
|
||||
|
||||
# Add markers at specific positions to test rendering
|
||||
# Mark the center
|
||||
center_x, center_y = 40, 25
|
||||
for dx in range(-2, 3):
|
||||
for dy in range(-2, 3):
|
||||
grid3.at(center_x + dx, center_y + dy).color = mcrfpy.Color(255, 0, 255, 255) # Magenta
|
||||
|
||||
# Mark position at 1920 pixel boundary (64 tiles * 30 pixels/tile = 1920)
|
||||
if 64 < 80: # Only if within grid bounds
|
||||
for y in range(min(50, 10)):
|
||||
grid3.at(64, y).color = mcrfpy.Color(255, 128, 0, 255) # Orange
|
||||
|
||||
mcrfpy.step(0.01)
|
||||
automation.screenshot("/tmp/issue_9_large_grid.png")
|
||||
print("EXPECTED ISSUE: Large grid created")
|
||||
print(" Content beyond 1920x1080 will not render!")
|
||||
print(" Look for missing orange line at x=1920 boundary")
|
||||
|
||||
# Test 5: Dynamic resize test
|
||||
print("\n--- Test 5: Dynamic Resize Test ---")
|
||||
scene_ui.remove(0)
|
||||
|
||||
grid4 = mcrfpy.Grid(100, 100)
|
||||
grid4.x = 10
|
||||
grid4.y = 10
|
||||
scene_ui.append(grid4)
|
||||
|
||||
sizes = [(500, 500), (1000, 1000), (1500, 1500), (2000, 2000), (2500, 2500)]
|
||||
|
||||
for i, (w, h) in enumerate(sizes):
|
||||
grid4.w = w
|
||||
grid4.h = h
|
||||
|
||||
# Add pattern at current size
|
||||
visible_tiles_x = min(100, w // 30)
|
||||
visible_tiles_y = min(100, h // 30)
|
||||
|
||||
# Clear and create new pattern
|
||||
for x in range(visible_tiles_x):
|
||||
for y in range(visible_tiles_y):
|
||||
if x == visible_tiles_x - 1 or y == visible_tiles_y - 1:
|
||||
# Edge markers
|
||||
grid4.at(x, y).color = mcrfpy.Color(255, 255, 0, 255)
|
||||
elif (x + y) % 10 == 0:
|
||||
# Diagonal lines
|
||||
grid4.at(x, y).color = mcrfpy.Color(0, 255, 255, 255)
|
||||
|
||||
mcrfpy.step(0.01)
|
||||
automation.screenshot(f"/tmp/issue_9_resize_{w}x{h}.png")
|
||||
|
||||
if w > 1920 or h > 1080:
|
||||
print(f"FAIL: Size {w}x{h}: Content clipped at 1920x1080")
|
||||
else:
|
||||
print(f"PASS: Size {w}x{h}: Rendered correctly")
|
||||
|
||||
# Test 6: Verify exact clipping boundary
|
||||
print("\n--- Test 6: Exact Clipping Boundary Test ---")
|
||||
scene_ui.remove(0)
|
||||
|
||||
grid5 = mcrfpy.Grid(70, 40)
|
||||
grid5.x = 0
|
||||
grid5.y = 0
|
||||
grid5.w = 2100 # 70 * 30 = 2100 pixels
|
||||
grid5.h = 1200 # 40 * 30 = 1200 pixels
|
||||
scene_ui.append(grid5)
|
||||
|
||||
# Create a pattern that shows the boundary clearly
|
||||
for x in range(70):
|
||||
for y in range(40):
|
||||
pixel_x = x * 30
|
||||
pixel_y = y * 30
|
||||
|
||||
if pixel_x == 1920 - 30: # Last tile before boundary
|
||||
grid5.at(x, y).color = mcrfpy.Color(255, 0, 0, 255) # Red
|
||||
elif pixel_x == 1920: # First tile after boundary
|
||||
grid5.at(x, y).color = mcrfpy.Color(0, 255, 0, 255) # Green
|
||||
elif pixel_y == 1080 - 30: # Last row before boundary
|
||||
grid5.at(x, y).color = mcrfpy.Color(0, 0, 255, 255) # Blue
|
||||
elif pixel_y == 1080: # First row after boundary
|
||||
grid5.at(x, y).color = mcrfpy.Color(255, 255, 0, 255) # Yellow
|
||||
else:
|
||||
# Normal checkerboard
|
||||
if (x + y) % 2 == 0:
|
||||
grid5.at(x, y).color = mcrfpy.Color(200, 200, 200, 255)
|
||||
|
||||
mcrfpy.step(0.01)
|
||||
automation.screenshot("/tmp/issue_9_boundary_test.png")
|
||||
print("Screenshot saved showing clipping boundary")
|
||||
print("- Red tiles: Last visible column (x=1890-1919)")
|
||||
print("- Green tiles: First clipped column (x=1920+)")
|
||||
print("- Blue tiles: Last visible row (y=1050-1079)")
|
||||
print("- Yellow tiles: First clipped row (y=1080+)")
|
||||
|
||||
# Summary
|
||||
print("\n=== SUMMARY ===")
|
||||
print("Issue #9: UIGrid uses a hardcoded RenderTexture size of 1920x1080")
|
||||
print("Problems demonstrated:")
|
||||
print("1. Grids larger than 1920x1080 are clipped")
|
||||
print("2. Resizing grids doesn't recreate the RenderTexture")
|
||||
print("3. Content beyond the boundary is not rendered")
|
||||
print("\nThe fix should:")
|
||||
print("1. Recreate RenderTexture when grid size changes")
|
||||
print("2. Use the actual grid dimensions instead of hardcoded values")
|
||||
print("3. Consider memory limits for very large grids")
|
||||
if FAILURES:
|
||||
print("FAIL: %d check(s) failed: %s" % (len(FAILURES), ", ".join(FAILURES)))
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\nScreenshots saved to /tmp/issue_9_*.png")
|
||||
print("\nTest complete - check screenshots for visual verification")
|
||||
print("Screenshots saved to /tmp/issue_9_*.png")
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -25,8 +25,7 @@ def recursive_callback(target, prop, value):
|
|||
callback_count += 1
|
||||
|
||||
if callback_count >= MAX_CALLBACKS:
|
||||
print(f"PASS - {callback_count} recursive animation callbacks completed without segfault")
|
||||
sys.exit(0)
|
||||
return
|
||||
|
||||
# Chain another animation - this used to cause segfault due to iterator invalidation
|
||||
target.animate("x", 100 + (callback_count * 20), 0.1, mcrfpy.Easing.LINEAR, callback=recursive_callback)
|
||||
|
|
@ -34,13 +33,29 @@ def recursive_callback(target, prop, value):
|
|||
# Start the chain
|
||||
frame.animate("x", 200, 0.1, mcrfpy.Easing.LINEAR, callback=recursive_callback)
|
||||
|
||||
def timeout_check(timer, runtime):
|
||||
"""Safety timeout"""
|
||||
if callback_count >= MAX_CALLBACKS:
|
||||
print(f"PASS - {callback_count} callbacks completed")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(f"FAIL - only {callback_count}/{MAX_CALLBACKS} callbacks executed")
|
||||
sys.exit(1)
|
||||
# In headless mode mcrfpy.step() is the only clock: drive the animation chain forward.
|
||||
# Each link is 0.1s; MAX_CALLBACKS links need >= 1.0s of simulated time. Step generously
|
||||
# and bail out as soon as the chain is complete.
|
||||
MAX_STEPS = 400
|
||||
steps = 0
|
||||
while callback_count < MAX_CALLBACKS and steps < MAX_STEPS:
|
||||
mcrfpy.step(0.02)
|
||||
steps += 1
|
||||
|
||||
safety_timer = mcrfpy.Timer("safety", timeout_check, 5000, once=True)
|
||||
if callback_count < MAX_CALLBACKS:
|
||||
print(f"FAIL - only {callback_count}/{MAX_CALLBACKS} callbacks executed after {steps} steps")
|
||||
sys.exit(1)
|
||||
|
||||
# The chain completed without segfault (iterator invalidation in AnimationManager::update).
|
||||
# The final animation of the chain targets x = 100 + (MAX_CALLBACKS-1)*20; run a few more
|
||||
# steps so it settles, proving the manager is still healthy after the recursive churn.
|
||||
for _ in range(20):
|
||||
mcrfpy.step(0.02)
|
||||
|
||||
expected_x = 100 + (MAX_CALLBACKS - 1) * 20
|
||||
if abs(frame.x - expected_x) > 0.5:
|
||||
print(f"FAIL - final animation did not complete: frame.x={frame.x}, expected {expected_x}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"PASS - {callback_count} recursive animation callbacks completed without segfault; frame.x={frame.x}")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,18 @@ SANITIZER_PATTERNS = [
|
|||
]
|
||||
|
||||
# Test directories to run (in order)
|
||||
TEST_DIRS = ['unit', 'integration', 'regression']
|
||||
TEST_DIRS = ['unit', 'integration', 'regression', 'demo']
|
||||
|
||||
# #372: tests/demo/ was never run by anything, so the demo screens -- which CLAUDE.md
|
||||
# points at as the canonical API-usage examples -- silently bitrotted until they could
|
||||
# not even import. Smoke-gate the entry point that exercises screens/base.py and every
|
||||
# wired DemoScreen, so "read the demos for correct usage" stays true.
|
||||
#
|
||||
# An allowlist, not a glob: the other scripts under tests/demo/ are standalone
|
||||
# showcases that are not part of the demo_main path (see the follow-up issue for
|
||||
# adopting them). Demos render, so they need a longer timeout than a unit test.
|
||||
DEMO_SMOKE_TESTS = ['demo_main.py']
|
||||
DEMO_TIMEOUT = 60 # seconds
|
||||
|
||||
# ANSI colors
|
||||
GREEN = '\033[92m'
|
||||
|
|
@ -113,6 +124,13 @@ def run_test(test_path, verbose=False, timeout=DEFAULT_TIMEOUT,
|
|||
passed = result.returncode == 0
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
# An uncaught exception is never a pass. Exit code alone was not enough:
|
||||
# a script that raised during setup registered no timers, was auto-exited 0
|
||||
# by the engine, and was scored PASS -- 59 tests sat rotted this way, their
|
||||
# assertions never once executing. (#341/#350/#372)
|
||||
if 'Traceback (most recent call last)' in output:
|
||||
passed = False
|
||||
|
||||
# Check for PASS/FAIL in output
|
||||
if 'FAIL' in output and 'PASS' not in output.split('FAIL')[-1]:
|
||||
passed = False
|
||||
|
|
@ -150,6 +168,9 @@ def find_tests(directory):
|
|||
test_dir = TESTS_DIR / directory
|
||||
if not test_dir.exists():
|
||||
return []
|
||||
if directory == 'demo':
|
||||
# #372: allowlisted entry points only -- see DEMO_SMOKE_TESTS.
|
||||
return [test_dir / name for name in DEMO_SMOKE_TESTS if (test_dir / name).exists()]
|
||||
return sorted(test_dir.glob("*.py"))
|
||||
|
||||
def main():
|
||||
|
|
@ -203,8 +224,11 @@ def main():
|
|||
|
||||
for test_path in tests:
|
||||
test_name = test_path.name
|
||||
# Demos render several scenes; they need more headroom than a unit test.
|
||||
test_timeout = (DEMO_TIMEOUT * timeout_multiplier
|
||||
if test_dir == 'demo' else effective_timeout)
|
||||
passed, duration, output, san_errors = run_test(
|
||||
test_path, verbose, effective_timeout,
|
||||
test_path, verbose, test_timeout,
|
||||
sanitizer_mode=sanitizer_mode,
|
||||
valgrind_mode=valgrind_mode
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,15 @@ const keyboard: Keyboard
|
|||
const mouse: Mouse
|
||||
const window: Window
|
||||
|
||||
=== MODULE DYNAMIC ATTRIBUTES (7) ===
|
||||
attr animations (ro) :: Tuple of all currently running Animation objects (tuple, read-only).
|
||||
attr current_scene (rw) :: The active scene (Scene). Assign a Scene object, or a scene name as a string, to switch scenes.
|
||||
attr default_transition (rw) :: Default transition (Transition) applied when switching scenes without an explicit transition.
|
||||
attr default_transition_duration (rw) :: Default scene-transition duration in seconds (float). Must be non-negative.
|
||||
attr save_dir (ro) :: Directory used for persistent save data (str, read-only). '/save' under Emscripten.
|
||||
attr scenes (ro) :: Tuple of all registered Scene objects (tuple, read-only).
|
||||
attr timers (ro) :: Tuple of all active Timer objects (tuple, read-only).
|
||||
|
||||
=== SUBMODULES (1) ===
|
||||
submodule automation
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,25 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Example of CORRECT test pattern using mcrfpy.step() for automation
|
||||
Refactored from timer-based approach to synchronous step() pattern.
|
||||
|
||||
Note (#350/#341): step() is the simulation clock and never renders;
|
||||
automation.screenshot() is what forces a render of the current scene.
|
||||
"""
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
from datetime import datetime
|
||||
import os
|
||||
import sys
|
||||
|
||||
failures = []
|
||||
|
||||
def check(condition, message):
|
||||
if condition:
|
||||
print(f" ok: {message}")
|
||||
else:
|
||||
print(f" FAIL: {message}")
|
||||
failures.append(message)
|
||||
|
||||
# This code runs during --exec script execution
|
||||
print("=== Setting Up Test Scene ===")
|
||||
|
||||
|
|
@ -32,11 +45,11 @@ caption.font_size = 24
|
|||
frame.children.append(caption)
|
||||
|
||||
# Add click handler to demonstrate interaction
|
||||
click_received = False
|
||||
def frame_clicked(x, y, button):
|
||||
global click_received
|
||||
click_received = True
|
||||
print(f"Frame clicked at ({x}, {y}) with button {button}")
|
||||
# (#230) on_click receives (pos: Vector, button: MouseButton, action: InputState)
|
||||
clicks_received = []
|
||||
def frame_clicked(pos, button, action):
|
||||
clicks_received.append((pos, button, action))
|
||||
print(f"Frame clicked at ({pos.x}, {pos.y}) with button {button} ({action})")
|
||||
|
||||
frame.on_click = frame_clicked
|
||||
|
||||
|
|
@ -54,13 +67,25 @@ filename = f"WORKING_screenshot_{timestamp}.png"
|
|||
# Take screenshot - this should now show our red frame
|
||||
result = automation.screenshot(filename)
|
||||
print(f"Screenshot taken: {filename} - Result: {result}")
|
||||
check(result is True, "screenshot() reported success")
|
||||
check(os.path.exists(filename), f"{filename} exists on disk")
|
||||
# A blank 1024x768 PNG compresses to a few hundred bytes; real content is far bigger
|
||||
check(os.path.getsize(filename) > 2000,
|
||||
f"{filename} contains rendered content (size {os.path.getsize(filename)} > 2000)")
|
||||
|
||||
# Test clicking on the frame
|
||||
automation.click(200, 200) # Click in center of red frame
|
||||
automation.click((200, 200)) # Click in center of red frame
|
||||
|
||||
# Step to process the click
|
||||
mcrfpy.step(0.1)
|
||||
|
||||
check(len(clicks_received) > 0, "frame.on_click fired for click inside the frame")
|
||||
if clicks_received:
|
||||
pos, button, action = clicks_received[0]
|
||||
check((pos.x, pos.y) == (200.0, 200.0), "click reported the position it was sent to")
|
||||
check(button == mcrfpy.MouseButton.LEFT, "click reported the LEFT button")
|
||||
check(action == mcrfpy.InputState.PRESSED, "click reported the PRESSED state")
|
||||
|
||||
# Test keyboard input
|
||||
automation.typewrite("Hello from step-based test!")
|
||||
|
||||
|
|
@ -69,14 +94,26 @@ mcrfpy.step(0.1)
|
|||
|
||||
# Take another screenshot to show any changes
|
||||
filename2 = f"WORKING_screenshot_after_click_{timestamp}.png"
|
||||
automation.screenshot(filename2)
|
||||
result2 = automation.screenshot(filename2)
|
||||
print(f"Second screenshot: {filename2}")
|
||||
check(result2 is True, "second screenshot() reported success")
|
||||
check(os.path.exists(filename2), f"{filename2} exists on disk")
|
||||
|
||||
# Clean up the artifacts this test produced
|
||||
for f in (filename, filename2):
|
||||
if os.path.exists(f):
|
||||
os.remove(f)
|
||||
|
||||
print("Test completed successfully!")
|
||||
print("\nThis works because:")
|
||||
print("1. mcrfpy.step() advances simulation synchronously")
|
||||
print("2. The scene renders during step() calls")
|
||||
print("2. automation.screenshot() forces a render of the active scene")
|
||||
print("3. The RenderTexture contains actual rendered content")
|
||||
|
||||
print("PASS")
|
||||
if failures:
|
||||
print(f"\nFAIL: {len(failures)} check(s) failed")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
|
||||
print("\nPASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -90,12 +90,21 @@ for _ in range(10):
|
|||
check("replacement anim completes", anim5b.is_complete == True)
|
||||
|
||||
|
||||
# --- Test 6: Animation object created with loop=True via constructor ---
|
||||
anim6 = mcrfpy.Animation("x", 100.0, 1.0, loop=True)
|
||||
check("Animation constructor loop=True", anim6.is_looping == True)
|
||||
# --- Test 6: loop flag set at Animation creation time ---
|
||||
# mcrfpy.Animation is no longer exported; the successor factory is target.animate(),
|
||||
# which returns the Animation object. Same intent: the loop keyword is honored at
|
||||
# creation, and defaults to False.
|
||||
sprite6 = mcrfpy.Sprite(pos=(0, 0))
|
||||
scene.children.append(sprite6)
|
||||
|
||||
anim7 = mcrfpy.Animation("x", 100.0, 1.0)
|
||||
check("Animation constructor default loop=False", anim7.is_looping == False)
|
||||
anim6 = sprite6.animate("x", 100.0, 1.0, loop=True)
|
||||
check("animate(loop=True) sets is_looping", anim6.is_looping == True)
|
||||
|
||||
anim7 = sprite6.animate("y", 100.0, 1.0)
|
||||
check("animate() default loop=False", anim7.is_looping == False)
|
||||
|
||||
anim8 = sprite6.animate("z_index", 5, 1.0, loop=False)
|
||||
check("animate(loop=False) sets is_looping False", anim8.is_looping == False)
|
||||
|
||||
|
||||
# --- Summary ---
|
||||
|
|
|
|||
|
|
@ -1,34 +1,65 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test for mcrfpy.createScene() method"""
|
||||
"""Test for mcrfpy.Scene() creation and activation (formerly mcrfpy.createScene())"""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
def test_createScene():
|
||||
"""Test creating a new scene"""
|
||||
failures = []
|
||||
scenes = {}
|
||||
|
||||
# Test creating scenes
|
||||
test_scenes = ["test_scene1", "test_scene2", "special_chars_!@#"]
|
||||
|
||||
|
||||
for scene_name in test_scenes:
|
||||
try:
|
||||
_scene = mcrfpy.Scene(scene_name)
|
||||
print(f"✓ Created scene: {scene_name}")
|
||||
scenes[scene_name] = mcrfpy.Scene(scene_name)
|
||||
print(f"PASS: Created scene: {scene_name}")
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to create scene {scene_name}: {e}")
|
||||
return
|
||||
|
||||
# Try to set scene to verify it was created
|
||||
try:
|
||||
test_scene1.activate() # Note: ensure scene was created
|
||||
current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
|
||||
if current == "test_scene1":
|
||||
print("✓ Scene switching works correctly")
|
||||
else:
|
||||
print(f"✗ Scene switch failed: expected 'test_scene1', got '{current}'")
|
||||
except Exception as e:
|
||||
print(f"✗ Scene switching error: {e}")
|
||||
|
||||
print("PASS")
|
||||
print(f"FAIL: Failed to create scene {scene_name}: {e}")
|
||||
failures.append(f"create {scene_name}")
|
||||
continue
|
||||
|
||||
if scenes[scene_name].name != scene_name:
|
||||
print(f"FAIL: scene.name mismatch: expected '{scene_name}', got '{scenes[scene_name].name}'")
|
||||
failures.append(f"name {scene_name}")
|
||||
|
||||
# Activate a created scene to verify it exists and is switchable
|
||||
if "test_scene1" in scenes:
|
||||
try:
|
||||
scenes["test_scene1"].activate()
|
||||
current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
|
||||
if current == "test_scene1":
|
||||
print("PASS: Scene switching works correctly")
|
||||
else:
|
||||
print(f"FAIL: Scene switch failed: expected 'test_scene1', got '{current}'")
|
||||
failures.append("activate()")
|
||||
except Exception as e:
|
||||
print(f"FAIL: Scene switching error: {e}")
|
||||
failures.append("activate()")
|
||||
|
||||
# current_scene is also writable directly
|
||||
try:
|
||||
mcrfpy.current_scene = scenes["test_scene2"]
|
||||
current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
|
||||
if current == "test_scene2":
|
||||
print("PASS: mcrfpy.current_scene assignment works correctly")
|
||||
else:
|
||||
print(f"FAIL: current_scene assignment failed: expected 'test_scene2', got '{current}'")
|
||||
failures.append("current_scene=")
|
||||
except Exception as e:
|
||||
print(f"FAIL: current_scene assignment error: {e}")
|
||||
failures.append("current_scene=")
|
||||
|
||||
return failures
|
||||
|
||||
# Run test immediately
|
||||
print("Running createScene test...")
|
||||
test_createScene()
|
||||
print("Test completed.")
|
||||
failures = test_createScene()
|
||||
print("Test completed.")
|
||||
|
||||
if failures:
|
||||
print("FAIL: " + ", ".join(failures))
|
||||
sys.exit(1)
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,44 +1,78 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test for mcrfpy.setScene() and currentScene() methods"""
|
||||
"""Test for mcrfpy.current_scene (successor to setScene()/currentScene())"""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
print("Starting setScene/currentScene test...")
|
||||
print("Starting current_scene test...")
|
||||
|
||||
# Create test scenes first
|
||||
scenes = ["scene_A", "scene_B", "scene_C"]
|
||||
scene_objs = {}
|
||||
for scene in scenes:
|
||||
_scene = mcrfpy.Scene(scene)
|
||||
scene_objs[scene] = mcrfpy.Scene(scene)
|
||||
print(f"Created scene: {scene}")
|
||||
|
||||
results = []
|
||||
|
||||
# Test switching between scenes
|
||||
# Test switching between scenes by name (mcrfpy.current_scene is read/write)
|
||||
for scene in scenes:
|
||||
try:
|
||||
mcrfpy.current_scene = scene
|
||||
current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
|
||||
if current == scene:
|
||||
results.append(f"✓ setScene/currentScene works for '{scene}'")
|
||||
results.append(f"[ok] current_scene set/get by name works for '{scene}'")
|
||||
else:
|
||||
results.append(f"✗ Scene mismatch: set '{scene}', got '{current}'")
|
||||
results.append(f"[FAIL] Scene mismatch: set '{scene}', got '{current}'")
|
||||
except Exception as e:
|
||||
results.append(f"✗ Error with scene '{scene}': {e}")
|
||||
results.append(f"[FAIL] Error with scene '{scene}': {e}")
|
||||
|
||||
# Test invalid scene - it should not change the current scene
|
||||
# Test switching by Scene object, and via Scene.activate()
|
||||
for scene in scenes:
|
||||
try:
|
||||
mcrfpy.current_scene = scene_objs[scene]
|
||||
current = mcrfpy.current_scene
|
||||
if current.name == scene and current.active:
|
||||
results.append(f"[ok] current_scene set by Scene object works for '{scene}'")
|
||||
else:
|
||||
results.append(f"[FAIL] Object assign mismatch: set '{scene}', got '{current.name}'")
|
||||
except Exception as e:
|
||||
results.append(f"[FAIL] Error assigning Scene object '{scene}': {e}")
|
||||
|
||||
try:
|
||||
scene_objs["scene_B"].activate()
|
||||
if mcrfpy.current_scene.name == "scene_B":
|
||||
results.append("[ok] Scene.activate() switches the current scene")
|
||||
else:
|
||||
results.append(f"[FAIL] activate() left current scene at '{mcrfpy.current_scene.name}'")
|
||||
except Exception as e:
|
||||
results.append(f"[FAIL] Error in Scene.activate(): {e}")
|
||||
|
||||
# Test invalid scene - it must not change the current scene.
|
||||
# Current contract: assigning an unknown scene name raises KeyError and the
|
||||
# current scene is left untouched (the old setScene() silently ignored it).
|
||||
current_before = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
|
||||
nonexistent_scene.activate() # Note: ensure scene was created
|
||||
try:
|
||||
mcrfpy.current_scene = "nonexistent_scene"
|
||||
results.append("[FAIL] Assigning a nonexistent scene name did not raise")
|
||||
except KeyError:
|
||||
results.append("[ok] Assigning a nonexistent scene name raises KeyError")
|
||||
except Exception as e:
|
||||
results.append(f"[FAIL] Expected KeyError for nonexistent scene, got {type(e).__name__}: {e}")
|
||||
|
||||
current_after = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
|
||||
if current_before == current_after:
|
||||
results.append(f"✓ setScene correctly ignores nonexistent scene (stayed on '{current_after}')")
|
||||
results.append(f"[ok] Failed switch leaves current scene alone (stayed on '{current_after}')")
|
||||
else:
|
||||
results.append(f"✗ Scene changed unexpectedly from '{current_before}' to '{current_after}'")
|
||||
results.append(f"[FAIL] Scene changed unexpectedly from '{current_before}' to '{current_after}'")
|
||||
|
||||
# Print results
|
||||
for result in results:
|
||||
print(result)
|
||||
|
||||
# Determine pass/fail
|
||||
if all("✓" in r for r in results):
|
||||
if all("[ok]" in r for r in results):
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("FAIL")
|
||||
print("FAIL")
|
||||
sys.exit(1)
|
||||
|
|
|
|||
|
|
@ -270,7 +270,16 @@ def build_snapshot():
|
|||
out.append("# Regenerate intentionally: MCRF_UPDATE_API_SNAPSHOT=1")
|
||||
out.append("# Singleton/constant VALUES are intentionally NOT captured.")
|
||||
|
||||
names = [n for n in dir(mcrfpy) if not n.startswith("_")]
|
||||
# #356: the dynamic module attributes are getset descriptors on type(mcrfpy).
|
||||
# Capture them from the DESCRIPTOR, not from getattr(): their value is runtime
|
||||
# state (current_scene is None here, a Scene in a running game), so snapshotting
|
||||
# type(value) would make the golden depend on engine state.
|
||||
dynamic_attrs = sorted(
|
||||
n for n, d in vars(type(mcrfpy)).items()
|
||||
if not n.startswith("_") and isinstance(d, types.GetSetDescriptorType)
|
||||
)
|
||||
|
||||
names = [n for n in dir(mcrfpy) if not n.startswith("_") and n not in dynamic_attrs]
|
||||
enums, classes, funcs, singletons, submodules = [], [], [], [], []
|
||||
for n in names:
|
||||
v = getattr(mcrfpy, n)
|
||||
|
|
@ -298,6 +307,16 @@ def build_snapshot():
|
|||
for n, tn in sorted(singletons):
|
||||
out.append("const %s: %s" % (n, tn))
|
||||
|
||||
out.append("")
|
||||
out.append("=== MODULE DYNAMIC ATTRIBUTES (%d) ===" % len(dynamic_attrs))
|
||||
for n in dynamic_attrs:
|
||||
descr = getattr(type(mcrfpy), n)
|
||||
doc = descr.__doc__ or ""
|
||||
# A getset_descriptor is always a data descriptor (it exposes __set__ even
|
||||
# with a NULL setter), so read-only-ness can only be read off the docstring.
|
||||
rw = "ro" if "read-only" in doc.lower() else "rw"
|
||||
out.append("attr %s (%s) :: %s" % (n, rw, first_doc_line(doc) or "<no-doc>"))
|
||||
|
||||
out.append("")
|
||||
out.append("=== SUBMODULES (%d) ===" % len(submodules))
|
||||
for n in sorted(submodules):
|
||||
|
|
|
|||
|
|
@ -1,80 +1,125 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Debug empty paths issue"""
|
||||
"""Debug empty paths issue
|
||||
|
||||
Original intent: pathfinding was returning EMPTY paths on a fully-walkable grid.
|
||||
This test verifies that A* and Dijkstra both return non-empty paths, that walls
|
||||
are respected (detour), that an unreachable/blocked destination yields no path,
|
||||
and that the TCOD map stays in sync with walkability changes.
|
||||
|
||||
API notes (2026-07): compute_astar_path/compute_dijkstra/get_dijkstra_path are gone.
|
||||
Pathfinding lives on GridData: find_path() -> AStarPath | None,
|
||||
get_dijkstra_map(root=...) -> DijkstraMap (cached; clear_dijkstra_maps() after
|
||||
walkability changes). TCOD sync is automatic -- there is no sync_tcod_map().
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
print("Debugging empty paths...")
|
||||
|
||||
failures = []
|
||||
|
||||
def check(cond, msg):
|
||||
if cond:
|
||||
print(f" OK: {msg}")
|
||||
else:
|
||||
print(f" FAIL: {msg}")
|
||||
failures.append(msg)
|
||||
|
||||
# Create scene and grid
|
||||
debug = mcrfpy.Scene("debug")
|
||||
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(320, 320))
|
||||
data = grid.grid_data
|
||||
|
||||
# Initialize grid - all walkable
|
||||
print("\nInitializing grid...")
|
||||
for y in range(10):
|
||||
for x in range(10):
|
||||
grid.at(x, y).walkable = True
|
||||
data.at(x, y).walkable = True
|
||||
|
||||
# Test simple path
|
||||
print("\nTest 1: Simple path from (0,0) to (5,5)")
|
||||
path = grid.compute_astar_path(0, 0, 5, 5)
|
||||
print(f" A* path: {path}")
|
||||
print(f" Path length: {len(path)}")
|
||||
path = data.find_path((0, 0), (5, 5))
|
||||
check(path is not None, "A* found a path on an all-walkable grid")
|
||||
steps = list(path) if path else []
|
||||
print(f" A* path: {steps}")
|
||||
print(f" Path length: {len(steps)}")
|
||||
check(len(steps) > 0, "A* path is not empty")
|
||||
check(path is not None and tuple(path.destination) == (5, 5), "A* path ends at (5,5)")
|
||||
|
||||
# Test with Dijkstra
|
||||
print("\nTest 2: Same path with Dijkstra")
|
||||
grid.compute_dijkstra(0, 0)
|
||||
dpath = grid.get_dijkstra_path(5, 5)
|
||||
print(f" Dijkstra path: {dpath}")
|
||||
print(f" Path length: {len(dpath)}")
|
||||
dmap = data.get_dijkstra_map(root=(0, 0))
|
||||
dpath = dmap.path_from((5, 5))
|
||||
dsteps = list(dpath) if dpath else []
|
||||
print(f" Dijkstra path: {dsteps}")
|
||||
print(f" Path length: {len(dsteps)}")
|
||||
check(len(dsteps) > 0, "Dijkstra path is not empty")
|
||||
check(dmap.distance((5, 5)) > 0, "Dijkstra distance to (5,5) is finite and positive")
|
||||
|
||||
# Check if grid is properly initialized
|
||||
print("\nTest 3: Checking grid cells")
|
||||
for y in range(3):
|
||||
for x in range(3):
|
||||
cell = grid.at(x, y)
|
||||
cell = data.at(x, y)
|
||||
print(f" Cell ({x},{y}): walkable={cell.walkable}")
|
||||
check(cell.walkable, f"cell ({x},{y}) reports walkable=True")
|
||||
|
||||
# Test with walls
|
||||
print("\nTest 4: Path with wall")
|
||||
grid.at(2, 2).walkable = False
|
||||
grid.at(3, 2).walkable = False
|
||||
grid.at(4, 2).walkable = False
|
||||
for wx in (2, 3, 4):
|
||||
data.at(wx, 2).walkable = False
|
||||
data.clear_dijkstra_maps() # walkability changed: invalidate cached maps
|
||||
print(" Added wall at y=2, x=2,3,4")
|
||||
|
||||
path2 = grid.compute_astar_path(0, 0, 5, 5)
|
||||
print(f" A* path with wall: {path2}")
|
||||
print(f" Path length: {len(path2)}")
|
||||
path2 = data.find_path((0, 0), (5, 5))
|
||||
check(path2 is not None, "A* still finds a path around the wall")
|
||||
steps2 = list(path2) if path2 else []
|
||||
print(f" A* path with wall: {steps2}")
|
||||
print(f" Path length: {len(steps2)}")
|
||||
check(len(steps2) > 0, "walled A* path is not empty")
|
||||
blocked = {(2, 2), (3, 2), (4, 2)}
|
||||
check(not any(tuple(p) in blocked for p in steps2),
|
||||
"A* path does not cross the wall cells (TCOD map synced with walkability)")
|
||||
|
||||
# Test invalid paths
|
||||
print("\nTest 5: Path to blocked cell")
|
||||
grid.at(9, 9).walkable = False
|
||||
path3 = grid.compute_astar_path(0, 0, 9, 9)
|
||||
data.at(9, 9).walkable = False
|
||||
data.clear_dijkstra_maps()
|
||||
path3 = data.find_path((0, 0), (9, 9))
|
||||
print(f" Path to blocked cell: {path3}")
|
||||
check(path3 is None or len(list(path3)) == 0,
|
||||
"no path is produced to a non-walkable destination")
|
||||
|
||||
# Check TCOD map sync
|
||||
print("\nTest 6: Verify TCOD map is synced")
|
||||
# Try to force a sync
|
||||
print(" Checking if syncTCODMap exists...")
|
||||
if hasattr(grid, 'sync_tcod_map'):
|
||||
print(" Calling sync_tcod_map()")
|
||||
grid.sync_tcod_map()
|
||||
else:
|
||||
print(" No sync_tcod_map method found")
|
||||
# Fully wall off (9,8) and (8,9) too -- (9,9) neighbors -- then re-open (9,9):
|
||||
# a stale TCOD map would still refuse the destination.
|
||||
data.at(9, 9).walkable = True
|
||||
data.clear_dijkstra_maps()
|
||||
path_reopened = data.find_path((0, 0), (9, 9))
|
||||
check(path_reopened is not None and len(list(path_reopened)) > 0,
|
||||
"re-opening a blocked cell makes it reachable again (automatic TCOD sync)")
|
||||
|
||||
# Try path again
|
||||
print("\nTest 7: Path after potential sync")
|
||||
path4 = grid.compute_astar_path(0, 0, 5, 5)
|
||||
print(f" A* path: {path4}")
|
||||
print("\nTest 7: Path after mutation round-trip")
|
||||
path4 = data.find_path((0, 0), (5, 5))
|
||||
steps4 = list(path4) if path4 else []
|
||||
print(f" A* path: {steps4}")
|
||||
check(len(steps4) > 0, "A* path (0,0)->(5,5) still non-empty after mutations")
|
||||
|
||||
def timer_cb(timer, runtime):
|
||||
sys.exit(0)
|
||||
|
||||
# Quick UI setup
|
||||
# Quick UI setup: the grid must survive being attached to a live scene.
|
||||
ui = debug.children
|
||||
ui.append(grid)
|
||||
debug.activate()
|
||||
exit_timer = mcrfpy.Timer("exit", timer_cb, 100, once=True)
|
||||
mcrfpy.step(0.1)
|
||||
check(mcrfpy.current_scene is debug, "grid attached to the active scene")
|
||||
|
||||
print("\nStarting timer...")
|
||||
if failures:
|
||||
print(f"\nFAIL ({len(failures)} check(s) failed):")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
|
||||
print("\nPASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,2 +1,34 @@
|
|||
# This script is intentionally empty
|
||||
pass
|
||||
# This script is intentionally (almost) empty.
|
||||
#
|
||||
# Intent: the engine must survive a script that sets nothing up -- no scene, no UI,
|
||||
# no timers -- and must not crash when the headless clock is driven in that state.
|
||||
#
|
||||
# Under the #350 exit contract a bare `pass` is no longer a valid --exec script:
|
||||
# falling off the end exits nonzero with a diagnostic. So the "do nothing" case is
|
||||
# now spelled as "do nothing, then exit 0", plus the checks that make it a real test.
|
||||
import sys
|
||||
|
||||
import mcrfpy
|
||||
|
||||
failures = []
|
||||
|
||||
# A script that never creates a scene must still find the engine in a sane state.
|
||||
if not hasattr(mcrfpy, "step"):
|
||||
failures.append("mcrfpy.step is missing")
|
||||
if not hasattr(mcrfpy, "current_scene"):
|
||||
failures.append("mcrfpy.current_scene is missing")
|
||||
|
||||
# Driving the clock with no scene / no timers / no animations must be a harmless no-op.
|
||||
try:
|
||||
for _ in range(3):
|
||||
mcrfpy.step(0.05)
|
||||
except Exception as e:
|
||||
failures.append("mcrfpy.step() raised with no scene set up: %r" % (e,))
|
||||
|
||||
if failures:
|
||||
for f in failures:
|
||||
print("FAIL:", f)
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,78 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test if calling mcrfpy.exit() prevents the >>> prompt"""
|
||||
import mcrfpy
|
||||
"""Test that mcrfpy.exit() shuts the engine down instead of leaving it running
|
||||
|
||||
Original intent: "does calling mcrfpy.exit() prevent the >>> prompt?" -- i.e. does
|
||||
exit() actually terminate the application rather than falling through into an
|
||||
interactive/never-ending loop?
|
||||
|
||||
Current contract (#350): mcrfpy.exit() calls GameEngine::quit(); it stops the run
|
||||
loop. It is NOT a SystemExit -- the Python script keeps executing after it, and a
|
||||
headless --exec script still states its own outcome with sys.exit(). So the real
|
||||
"did exit() work?" question is answered in a child process that DOES enter the run
|
||||
loop (--run-forever): with exit() it must terminate; without it, it must not.
|
||||
"""
|
||||
import mcrfpy
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
failures = []
|
||||
scratch = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "build")
|
||||
scratch = os.path.abspath(scratch)
|
||||
|
||||
# --- 1. exit() is a no-arg call returning None; it does not abort the interpreter ---
|
||||
print("Calling mcrfpy.exit() immediately...")
|
||||
mcrfpy.exit()
|
||||
print("This should not print if exit worked")
|
||||
result = mcrfpy.exit()
|
||||
print("This still prints: exit() quits the engine, it does not raise SystemExit")
|
||||
|
||||
if result is not None:
|
||||
failures.append("mcrfpy.exit() should return None, got %r" % (result,))
|
||||
|
||||
try:
|
||||
mcrfpy.exit(1)
|
||||
except TypeError:
|
||||
pass
|
||||
else:
|
||||
failures.append("mcrfpy.exit() takes no arguments; exit(1) should raise TypeError")
|
||||
|
||||
# --- 2. exit() really stops the run loop (the ">>> prompt"/hang the test was about) ---
|
||||
child_exit = os.path.join(scratch, "_exit_immediately_child_exit.py")
|
||||
child_stay = os.path.join(scratch, "_exit_immediately_child_stay.py")
|
||||
with open(child_exit, "w") as f:
|
||||
f.write("import mcrfpy\nmcrfpy.exit()\n")
|
||||
with open(child_stay, "w") as f:
|
||||
f.write("import mcrfpy\n")
|
||||
|
||||
try:
|
||||
# sys.executable is the mcrogueface binary itself.
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "--headless", "--run-forever", "--exec", child_exit],
|
||||
cwd=scratch, capture_output=True, text=True, timeout=20)
|
||||
if proc.returncode != 0:
|
||||
failures.append("exit() child should terminate cleanly, got returncode %d"
|
||||
% proc.returncode)
|
||||
except subprocess.TimeoutExpired:
|
||||
failures.append("exit() did not stop the run loop: --run-forever child hung")
|
||||
|
||||
# Control: without exit(), --run-forever must keep the process alive. This proves the
|
||||
# check above is actually observing exit(), not just a run loop that always ends.
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "--headless", "--run-forever", "--exec", child_stay],
|
||||
cwd=scratch, capture_output=True, text=True, timeout=5)
|
||||
failures.append("control child (no exit()) terminated on its own; "
|
||||
"the exit() check above proves nothing")
|
||||
except subprocess.TimeoutExpired:
|
||||
pass # expected: still running
|
||||
|
||||
for path in (child_exit, child_stay):
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
||||
if failures:
|
||||
for f in failures:
|
||||
print("FAIL: %s" % f)
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -3,17 +3,26 @@
|
|||
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
import os
|
||||
import sys
|
||||
|
||||
def capture_sprites(timer, runtime):
|
||||
"""Capture sprite examples after render loop starts"""
|
||||
SCREENSHOT = "ui_sprite_example.png"
|
||||
|
||||
# Take screenshot
|
||||
automation.screenshot("mcrogueface.github.io/images/ui_sprite_example.png")
|
||||
results = []
|
||||
|
||||
def check(label, condition):
|
||||
results.append((label, bool(condition)))
|
||||
print(f"{'PASS' if condition else 'FAIL'}: {label}")
|
||||
|
||||
def capture_sprites(timer, runtime):
|
||||
"""Capture sprite examples once the timer fires"""
|
||||
|
||||
# Take screenshot (rendering is on-demand in headless: screenshot forces a render)
|
||||
automation.screenshot(SCREENSHOT)
|
||||
print("Sprite screenshot saved!")
|
||||
|
||||
# Exit after capturing
|
||||
sys.exit(0)
|
||||
check("screenshot file created", os.path.exists(SCREENSHOT))
|
||||
check("screenshot is non-empty", os.path.exists(SCREENSHOT) and os.path.getsize(SCREENSHOT) > 0)
|
||||
|
||||
# Create scene
|
||||
sprites = mcrfpy.Scene("sprites")
|
||||
|
|
@ -21,9 +30,8 @@ sprites = mcrfpy.Scene("sprites")
|
|||
# Load texture
|
||||
texture = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16)
|
||||
|
||||
# Title
|
||||
title = mcrfpy.Caption(pos=(400, 30), text="Sprite Examples")
|
||||
title.font = mcrfpy.default_font
|
||||
# Title (Caption.font is read-only: pass it to the constructor)
|
||||
title = mcrfpy.Caption(pos=(400, 30), font=mcrfpy.default_font, text="Sprite Examples")
|
||||
title.font_size = 24
|
||||
title.fill_color = mcrfpy.Color(255, 255, 255)
|
||||
|
||||
|
|
@ -33,102 +41,71 @@ frame.fill_color = mcrfpy.Color(64, 64, 128)
|
|||
frame.outline = 2
|
||||
|
||||
# Player sprite
|
||||
player_label = mcrfpy.Caption(pos=(100, 120), text="Player")
|
||||
player_label.font = mcrfpy.default_font
|
||||
player_label = mcrfpy.Caption(pos=(100, 120), font=mcrfpy.default_font, text="Player")
|
||||
player_label.fill_color = mcrfpy.Color(255, 255, 255)
|
||||
|
||||
player = mcrfpy.Sprite(120, 150)
|
||||
player.texture = texture
|
||||
player.sprite_index = 84 # Player sprite
|
||||
player.scale = (3.0, 3.0)
|
||||
player = mcrfpy.Sprite(pos=(120, 150), texture=texture, sprite_index=84) # Player sprite
|
||||
player.scale = 3.0
|
||||
|
||||
# Enemy sprites
|
||||
enemy_label = mcrfpy.Caption(pos=(250, 120), text="Enemies")
|
||||
enemy_label.font = mcrfpy.default_font
|
||||
enemy_label = mcrfpy.Caption(pos=(250, 120), font=mcrfpy.default_font, text="Enemies")
|
||||
enemy_label.fill_color = mcrfpy.Color(255, 255, 255)
|
||||
|
||||
rat = mcrfpy.Sprite(250, 150)
|
||||
rat.texture = texture
|
||||
rat.sprite_index = 123 # Rat
|
||||
rat.scale = (3.0, 3.0)
|
||||
rat = mcrfpy.Sprite(pos=(250, 150), texture=texture, sprite_index=123) # Rat
|
||||
rat.scale = 3.0
|
||||
|
||||
big_rat = mcrfpy.Sprite(320, 150)
|
||||
big_rat.texture = texture
|
||||
big_rat.sprite_index = 130 # Big rat
|
||||
big_rat.scale = (3.0, 3.0)
|
||||
big_rat = mcrfpy.Sprite(pos=(320, 150), texture=texture, sprite_index=130) # Big rat
|
||||
big_rat.scale = 3.0
|
||||
|
||||
cyclops = mcrfpy.Sprite(390, 150)
|
||||
cyclops.texture = texture
|
||||
cyclops.sprite_index = 109 # Cyclops
|
||||
cyclops.scale = (3.0, 3.0)
|
||||
cyclops = mcrfpy.Sprite(pos=(390, 150), texture=texture, sprite_index=109) # Cyclops
|
||||
cyclops.scale = 3.0
|
||||
|
||||
# Items row
|
||||
items_label = mcrfpy.Caption(pos=(100, 250), text="Items")
|
||||
items_label.font = mcrfpy.default_font
|
||||
items_label = mcrfpy.Caption(pos=(100, 250), font=mcrfpy.default_font, text="Items")
|
||||
items_label.fill_color = mcrfpy.Color(255, 255, 255)
|
||||
|
||||
# Boulder
|
||||
boulder = mcrfpy.Sprite(100, 280)
|
||||
boulder.texture = texture
|
||||
boulder.sprite_index = 66 # Boulder
|
||||
boulder.scale = (3.0, 3.0)
|
||||
boulder = mcrfpy.Sprite(pos=(100, 280), texture=texture, sprite_index=66) # Boulder
|
||||
boulder.scale = 3.0
|
||||
|
||||
# Chest
|
||||
chest = mcrfpy.Sprite(170, 280)
|
||||
chest.texture = texture
|
||||
chest.sprite_index = 89 # Closed chest
|
||||
chest.scale = (3.0, 3.0)
|
||||
chest = mcrfpy.Sprite(pos=(170, 280), texture=texture, sprite_index=89) # Closed chest
|
||||
chest.scale = 3.0
|
||||
|
||||
# Key
|
||||
key = mcrfpy.Sprite(240, 280)
|
||||
key.texture = texture
|
||||
key.sprite_index = 384 # Key
|
||||
key.scale = (3.0, 3.0)
|
||||
key = mcrfpy.Sprite(pos=(240, 280), texture=texture, sprite_index=384) # Key
|
||||
key.scale = 3.0
|
||||
|
||||
# Button
|
||||
button = mcrfpy.Sprite(310, 280)
|
||||
button.texture = texture
|
||||
button.sprite_index = 250 # Button
|
||||
button.scale = (3.0, 3.0)
|
||||
button = mcrfpy.Sprite(pos=(310, 280), texture=texture, sprite_index=250) # Button
|
||||
button.scale = 3.0
|
||||
|
||||
# UI elements row
|
||||
ui_label = mcrfpy.Caption(pos=(100, 380), text="UI Elements")
|
||||
ui_label.font = mcrfpy.default_font
|
||||
ui_label = mcrfpy.Caption(pos=(100, 380), font=mcrfpy.default_font, text="UI Elements")
|
||||
ui_label.fill_color = mcrfpy.Color(255, 255, 255)
|
||||
|
||||
# Hearts
|
||||
heart_full = mcrfpy.Sprite(100, 410)
|
||||
heart_full.texture = texture
|
||||
heart_full.sprite_index = 210 # Full heart
|
||||
heart_full.scale = (3.0, 3.0)
|
||||
heart_full = mcrfpy.Sprite(pos=(100, 410), texture=texture, sprite_index=210) # Full heart
|
||||
heart_full.scale = 3.0
|
||||
|
||||
heart_half = mcrfpy.Sprite(170, 410)
|
||||
heart_half.texture = texture
|
||||
heart_half.sprite_index = 209 # Half heart
|
||||
heart_half.scale = (3.0, 3.0)
|
||||
heart_half = mcrfpy.Sprite(pos=(170, 410), texture=texture, sprite_index=209) # Half heart
|
||||
heart_half.scale = 3.0
|
||||
|
||||
heart_empty = mcrfpy.Sprite(240, 410)
|
||||
heart_empty.texture = texture
|
||||
heart_empty.sprite_index = 208 # Empty heart
|
||||
heart_empty.scale = (3.0, 3.0)
|
||||
heart_empty = mcrfpy.Sprite(pos=(240, 410), texture=texture, sprite_index=208) # Empty heart
|
||||
heart_empty.scale = 3.0
|
||||
|
||||
# Armor
|
||||
armor = mcrfpy.Sprite(340, 410)
|
||||
armor.texture = texture
|
||||
armor.sprite_index = 211 # Armor
|
||||
armor.scale = (3.0, 3.0)
|
||||
armor = mcrfpy.Sprite(pos=(340, 410), texture=texture, sprite_index=211) # Armor
|
||||
armor.scale = 3.0
|
||||
|
||||
# Scale demonstration
|
||||
scale_label = mcrfpy.Caption(pos=(500, 120), text="Scale Demo")
|
||||
scale_label.font = mcrfpy.default_font
|
||||
scale_label = mcrfpy.Caption(pos=(500, 120), font=mcrfpy.default_font, text="Scale Demo")
|
||||
scale_label.fill_color = mcrfpy.Color(255, 255, 255)
|
||||
|
||||
# Same sprite at different scales
|
||||
for i, scale in enumerate([1.0, 2.0, 3.0, 4.0]):
|
||||
s = mcrfpy.Sprite(500 + i * 60, 150)
|
||||
s.texture = texture
|
||||
s.sprite_index = 84 # Player
|
||||
s.scale = (scale, scale)
|
||||
s = mcrfpy.Sprite(pos=(500 + i * 60, 150), texture=texture, sprite_index=84) # Player
|
||||
s.scale = scale
|
||||
sprites.children.append(s)
|
||||
|
||||
# Add all elements to scene
|
||||
|
|
@ -156,5 +133,22 @@ ui.append(scale_label)
|
|||
# Switch to scene
|
||||
sprites.activate()
|
||||
|
||||
# Set timer to capture after rendering starts
|
||||
capture_timer = mcrfpy.Timer("capture", capture_sprites, 100, once=True)
|
||||
# Everything got built and attached
|
||||
check("scene populated with sprite showcase", len(sprites.children) == 23)
|
||||
check("sprite scale applied", player.scale == 3.0)
|
||||
check("sprite index applied", player.sprite_index == 84)
|
||||
|
||||
# Set timer to capture; headless has no clock of its own, so drive it with step()
|
||||
capture_timer = mcrfpy.Timer("capture", capture_sprites, 100, once=True)
|
||||
for _ in range(4):
|
||||
mcrfpy.step(0.05)
|
||||
|
||||
check("capture timer fired", any(label.startswith("screenshot") for label, _ in results))
|
||||
|
||||
failures = [label for label, ok in results if not ok]
|
||||
if failures:
|
||||
print(f"FAIL - {len(failures)} check(s) failed: {failures}")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ for i in range(1, 8):
|
|||
# Apply camera rotation
|
||||
grid.camera_rotation = 30.0 # 30 degree rotation
|
||||
grid.center_camera((4, 4)) # Center on middle of grid
|
||||
assert grid.camera_rotation == 30.0, "camera_rotation should round-trip"
|
||||
|
||||
ui.append(grid)
|
||||
|
||||
|
|
@ -46,6 +47,10 @@ for i in range(1, 8):
|
|||
|
||||
grid2.camera_rotation = 0.0 # No rotation
|
||||
grid2.center_camera((4, 4))
|
||||
assert grid2.camera_rotation == 0.0, "camera_rotation=0 should round-trip"
|
||||
|
||||
# camera_rotation is per-view state: rotating grid must not disturb grid2
|
||||
assert grid.camera_rotation == 30.0, "camera_rotation must be per-grid, not shared"
|
||||
|
||||
ui.append(grid2)
|
||||
|
||||
|
|
@ -69,6 +74,9 @@ for i in range(6):
|
|||
grid3.rotation = 15.0
|
||||
grid3.origin = (100, 75) # Center origin for rotation
|
||||
grid3.center_camera((3, 3))
|
||||
assert grid3.rotation == 15.0, "viewport rotation should round-trip"
|
||||
# viewport rotation is distinct from camera rotation
|
||||
assert grid3.camera_rotation == 0.0, "setting .rotation must not set .camera_rotation"
|
||||
|
||||
ui.append(grid3)
|
||||
|
||||
|
|
@ -76,9 +84,9 @@ label3 = mcrfpy.Caption(text="Grid with viewport rotation=15 (rotates entire wid
|
|||
ui.append(label3)
|
||||
|
||||
# Test center_camera computes correct pixel center
|
||||
# (GridView has no .cell_size accessor; the raw cell metrics aren't needed here --
|
||||
# center_camera is verified by the relationships between the centers it produces.)
|
||||
test_grid = mcrfpy.Grid(grid_size=(20, 15), pos=(0, 0), size=(320, 240))
|
||||
cell_w = test_grid.cell_size[0]
|
||||
cell_h = test_grid.cell_size[1]
|
||||
|
||||
# center_camera((0, 0)) should put tile (0,0) at view center
|
||||
test_grid.center_camera((0, 0))
|
||||
|
|
@ -90,6 +98,8 @@ c0 = test_grid.center
|
|||
test_grid.center_camera((10, 7))
|
||||
c1 = test_grid.center
|
||||
assert c0.x != c1.x or c0.y != c1.y, "center_camera at different positions should give different centers"
|
||||
# ...and it should move monotonically with the tile coordinate
|
||||
assert c1.x > c0.x and c1.y > c0.y, "center_camera to a larger tile should increase the center"
|
||||
|
||||
# center_camera at same position twice should be idempotent
|
||||
test_grid.center_camera((5, 5))
|
||||
|
|
|
|||
|
|
@ -73,24 +73,31 @@ def test_gridview_repr():
|
|||
print("PASS: GridView repr")
|
||||
|
||||
def test_gridview_grid_property():
|
||||
"""GridView.grid returns the correct Grid with identity preservation."""
|
||||
"""GridView.grid_data returns the shared GridData with identity preservation.
|
||||
|
||||
Contract change (#313/#361): a view no longer exposes `.grid` (the source view);
|
||||
Grid and GridView are the same type, and the map they share is `.grid_data`.
|
||||
"""
|
||||
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||
grid = mcrfpy.Grid(grid_size=(15, 10), texture=tex, pos=(0, 0), size=(240, 160))
|
||||
view = mcrfpy.GridView(grid=grid, pos=(250, 0), size=(240, 160))
|
||||
|
||||
assert view.grid is grid, "view.grid should be the same Grid object"
|
||||
assert view.grid.grid_w == 15
|
||||
assert view.grid_data is grid.grid_data, "view must share the source Grid's GridData"
|
||||
assert view.grid_data.grid_w == 15
|
||||
# Identity preserved on repeated access
|
||||
assert view.grid is view.grid
|
||||
print("PASS: GridView.grid property with identity")
|
||||
assert view.grid_data is view.grid_data
|
||||
print("PASS: GridView.grid_data property with identity")
|
||||
|
||||
def test_gridview_no_grid():
|
||||
"""GridView without a grid doesn't crash."""
|
||||
def test_gridview_default_grid():
|
||||
"""GridView with no source grid doesn't crash; it gets its own default GridData."""
|
||||
view = mcrfpy.GridView()
|
||||
assert view.grid is None
|
||||
gd = view.grid_data
|
||||
assert gd is not None, "a bare GridView still owns a default GridData"
|
||||
assert gd.grid_w > 0 and gd.grid_h > 0
|
||||
assert gd is view.grid_data # identity preserved
|
||||
r = repr(view)
|
||||
assert "None" in r
|
||||
print("PASS: GridView without grid")
|
||||
assert "GridView" in r
|
||||
print("PASS: GridView without a source grid")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_gridview_creation()
|
||||
|
|
@ -99,6 +106,6 @@ if __name__ == "__main__":
|
|||
test_gridview_multi_view()
|
||||
test_gridview_repr()
|
||||
test_gridview_grid_property()
|
||||
test_gridview_no_grid()
|
||||
test_gridview_default_grid()
|
||||
print("All GridView tests passed")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import mcrfpy
|
||||
import sys
|
||||
|
||||
scene = mcrfpy.Scene("test")
|
||||
scene.activate()
|
||||
f1 = mcrfpy.Frame((10,10), (100,100), fill_color = (255, 0, 0, 64))
|
||||
|
|
@ -8,7 +10,37 @@ f_child = mcrfpy.Frame((25,25), (50,50), fill_color = (0, 0, 255, 64))
|
|||
scene.children.append(f1)
|
||||
scene.children.append(f2)
|
||||
f1.children.append(f_child)
|
||||
|
||||
failures = []
|
||||
|
||||
# Before reparent: child belongs to f1
|
||||
if len(f1.children) != 1:
|
||||
failures.append("f1 should have 1 child before reparent, has %d" % len(f1.children))
|
||||
if len(f2.children) != 0:
|
||||
failures.append("f2 should have 0 children before reparent, has %d" % len(f2.children))
|
||||
if f_child.parent is not f1:
|
||||
failures.append("f_child.parent should be f1 before reparent, got %r" % (f_child.parent,))
|
||||
|
||||
# Reparent by assigning .parent
|
||||
f_child.parent = f2
|
||||
|
||||
print(f1.children)
|
||||
print(f2.children)
|
||||
|
||||
# After reparent: child moved out of f1 and into f2 (no duplication, no orphaning)
|
||||
if len(f1.children) != 0:
|
||||
failures.append("f1 should have 0 children after reparent, has %d" % len(f1.children))
|
||||
if len(f2.children) != 1:
|
||||
failures.append("f2 should have 1 child after reparent, has %d" % len(f2.children))
|
||||
elif f2.children[0] is not f_child:
|
||||
failures.append("f2's child is not f_child")
|
||||
if f_child.parent is not f2:
|
||||
failures.append("f_child.parent should be f2 after reparent, got %r" % (f_child.parent,))
|
||||
|
||||
if failures:
|
||||
for f in failures:
|
||||
print("FAIL: " + f)
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -188,10 +188,15 @@ check(f"standard easing complete() goes to target ({sprite9.x:.1f})",
|
|||
abs(sprite9.x - 500.0) < 5.0)
|
||||
|
||||
|
||||
# --- Test 11: Animation constructor with ping-pong easing ---
|
||||
anim10 = mcrfpy.Animation("x", 100.0, 1.0, mcrfpy.Easing.PING_PONG, loop=True)
|
||||
check("Animation constructor with PING_PONG", anim10 is not None)
|
||||
check("Animation constructor loop=True", anim10.is_looping == True)
|
||||
# --- Test 11: Animation construction with ping-pong easing ---
|
||||
# mcrfpy.Animation is no longer exported; animations are constructed via the
|
||||
# target's .animate() method, which returns the Animation object.
|
||||
sprite10 = mcrfpy.Sprite(pos=(0, 0))
|
||||
scene.children.append(sprite10)
|
||||
anim10 = sprite10.animate("x", 100.0, 1.0, mcrfpy.Easing.PING_PONG, loop=True)
|
||||
check("animate() with PING_PONG", anim10 is not None)
|
||||
check("animate() loop=True", anim10.is_looping == True)
|
||||
check("animate() ping-pong easing retained", anim10.is_complete == False)
|
||||
|
||||
|
||||
# --- Summary ---
|
||||
|
|
|
|||
|
|
@ -3,43 +3,80 @@
|
|||
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
failures = []
|
||||
fired = []
|
||||
|
||||
OUT_DIR = "test_screenshots"
|
||||
GOOD_PATH = os.path.join(OUT_DIR, "test_screenshot.png")
|
||||
BAD_PATH = os.path.join("no_such_dir_xyz", "test_screenshot.png")
|
||||
|
||||
|
||||
def take_screenshot(timer, runtime):
|
||||
"""Take screenshot after render starts"""
|
||||
"""Take screenshot after the timer fires"""
|
||||
print(f"Timer callback fired at runtime: {runtime}")
|
||||
fired.append(runtime)
|
||||
|
||||
# Try different paths
|
||||
paths = [
|
||||
"test_screenshot.png",
|
||||
"./test_screenshot.png",
|
||||
"mcrogueface.github.io/images/test_screenshot.png"
|
||||
]
|
||||
# A writable path must succeed and produce a real PNG file
|
||||
print(f"Trying to save to: {GOOD_PATH}")
|
||||
if automation.screenshot(GOOD_PATH) is not True:
|
||||
failures.append(f"screenshot({GOOD_PATH}) did not return True")
|
||||
return
|
||||
if not os.path.exists(GOOD_PATH):
|
||||
failures.append(f"screenshot reported success but {GOOD_PATH} does not exist")
|
||||
return
|
||||
size = os.path.getsize(GOOD_PATH)
|
||||
if size == 0:
|
||||
failures.append(f"{GOOD_PATH} is empty")
|
||||
return
|
||||
with open(GOOD_PATH, "rb") as f:
|
||||
magic = f.read(8)
|
||||
if magic != b"\x89PNG\r\n\x1a\n":
|
||||
failures.append(f"{GOOD_PATH} is not a PNG (magic={magic!r})")
|
||||
return
|
||||
print(f"Success: {GOOD_PATH} ({size} bytes)")
|
||||
|
||||
for path in paths:
|
||||
try:
|
||||
print(f"Trying to save to: {path}")
|
||||
automation.screenshot(path)
|
||||
print(f"Success: {path}")
|
||||
except Exception as e:
|
||||
print(f"Failed {path}: {e}")
|
||||
# An unwritable path must fail cleanly (return False, no exception)
|
||||
print(f"Trying to save to: {BAD_PATH}")
|
||||
if automation.screenshot(BAD_PATH) is not False:
|
||||
failures.append(f"screenshot({BAD_PATH}) should have returned False")
|
||||
return
|
||||
print(f"Failed as expected: {BAD_PATH}")
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
# Create minimal scene
|
||||
test = mcrfpy.Scene("test")
|
||||
|
||||
# Add a visible element
|
||||
caption = mcrfpy.Caption(pos=(100, 100), text="Screenshot Test")
|
||||
caption.font = mcrfpy.default_font
|
||||
# Add a visible element (Caption.font is read-only as of #320 -- set it in the ctor)
|
||||
caption = mcrfpy.Caption(pos=(100, 100), font=mcrfpy.default_font, text="Screenshot Test")
|
||||
caption.fill_color = mcrfpy.Color(255, 255, 255)
|
||||
caption.font_size = 24
|
||||
|
||||
test.children.append(caption)
|
||||
test.activate()
|
||||
|
||||
# Use timer to ensure rendering has started
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
if os.path.exists(GOOD_PATH):
|
||||
os.remove(GOOD_PATH)
|
||||
|
||||
# Timer fires at 500ms; headless has no clock of its own, so step() drives it (#350)
|
||||
print("Setting timer...")
|
||||
mcrfpy.Timer("screenshot", take_screenshot, 500, once=True) # Wait 0.5 seconds
|
||||
print("Timer set, entering game loop...")
|
||||
mcrfpy.Timer("screenshot", take_screenshot, 500, once=True)
|
||||
print("Timer set, advancing the clock...")
|
||||
for _ in range(20): # 20 * 50ms = 1000ms > 500ms
|
||||
mcrfpy.step(0.05)
|
||||
if fired:
|
||||
break
|
||||
|
||||
if not fired:
|
||||
failures.append("timer never fired after 1000ms of stepping")
|
||||
|
||||
if failures:
|
||||
for f in failures:
|
||||
print(f"FAIL: {f}")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@ print("=" * 30)
|
|||
|
||||
# Global state to track callback
|
||||
callback_count = 0
|
||||
callback_args = []
|
||||
|
||||
# #229 - Animation callbacks now receive (target, property, value) instead of (anim, target)
|
||||
def my_callback(target, prop, value):
|
||||
"""Simple callback that prints when animation completes"""
|
||||
global callback_count
|
||||
callback_count += 1
|
||||
callback_args.append((target, prop, value))
|
||||
print(f"Animation completed! Callback #{callback_count}")
|
||||
print(f" Target: {type(target).__name__}, Property: {prop}, Value: {value}")
|
||||
|
||||
|
|
@ -23,14 +25,15 @@ callback_demo = mcrfpy.Scene("callback_demo")
|
|||
callback_demo.activate()
|
||||
|
||||
# Create a frame to animate
|
||||
frame = mcrfpy.Frame((100, 100), (200, 200), fill_color=(255, 0, 0))
|
||||
frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200), fill_color=(255, 0, 0))
|
||||
ui = callback_demo.children
|
||||
ui.append(frame)
|
||||
|
||||
# Test 1: Animation with callback
|
||||
# mcrfpy.Animation is no longer exported; animations are constructed via the
|
||||
# target drawable's .animate() method, which starts them immediately.
|
||||
print("Starting animation with callback (1.0s duration)...")
|
||||
anim = mcrfpy.Animation("x", 400.0, 1.0, "easeInOutQuad", callback=my_callback)
|
||||
anim.start(frame)
|
||||
frame.animate("x", 400.0, 1.0, mcrfpy.Easing.EASE_IN_OUT_QUAD, callback=my_callback)
|
||||
|
||||
# Use mcrfpy.step() to advance past animation completion
|
||||
mcrfpy.step(1.5) # Advance 1.5 seconds - animation completes at 1.0s
|
||||
|
|
@ -38,12 +41,21 @@ mcrfpy.step(1.5) # Advance 1.5 seconds - animation completes at 1.0s
|
|||
if callback_count != 1:
|
||||
print(f"FAIL: Expected 1 callback, got {callback_count}")
|
||||
sys.exit(1)
|
||||
|
||||
# The callback receives (target, property, final_value) -- and the animation must
|
||||
# have actually driven the property to its target.
|
||||
cb_target, cb_prop, cb_value = callback_args[0]
|
||||
if cb_target is not frame or cb_prop != "x" or cb_value != 400.0:
|
||||
print(f"FAIL: Bad callback args: {callback_args[0]}")
|
||||
sys.exit(1)
|
||||
if frame.x != 400.0:
|
||||
print(f"FAIL: Expected frame.x == 400.0, got {frame.x}")
|
||||
sys.exit(1)
|
||||
print("SUCCESS: Callback fired exactly once!")
|
||||
|
||||
# Test 2: Animation without callback
|
||||
print("\nTesting animation without callback (0.5s duration)...")
|
||||
anim2 = mcrfpy.Animation("y", 300.0, 0.5, "linear")
|
||||
anim2.start(frame)
|
||||
frame.animate("y", 300.0, 0.5, mcrfpy.Easing.LINEAR)
|
||||
|
||||
# Advance past second animation
|
||||
mcrfpy.step(0.7)
|
||||
|
|
@ -51,7 +63,11 @@ mcrfpy.step(0.7)
|
|||
if callback_count != 1:
|
||||
print(f"FAIL: Callback count changed to {callback_count}")
|
||||
sys.exit(1)
|
||||
if frame.y != 300.0:
|
||||
print(f"FAIL: Expected frame.y == 300.0, got {frame.y}")
|
||||
sys.exit(1)
|
||||
|
||||
print("SUCCESS: No unexpected callbacks fired!")
|
||||
print("\nAnimation callback feature working correctly!")
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -3,15 +3,30 @@
|
|||
Test Animation Chaining
|
||||
=======================
|
||||
|
||||
Demonstrates proper animation chaining to avoid glitches.
|
||||
Demonstrates proper animation chaining to avoid glitches: an entity walks a path
|
||||
one tile at a time, and each step's animation only starts after the previous
|
||||
step's animation has completed (never two overlapping position animations).
|
||||
|
||||
Headless: mcrfpy.step(dt) is the clock -- it drives both timers and animations.
|
||||
Animations are created with target.animate(...) (mcrfpy.Animation is no longer
|
||||
constructible); chaining uses the completion callback instead of polling.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
failures = []
|
||||
|
||||
def check(name, condition, detail=""):
|
||||
if condition:
|
||||
print(f" PASS: {name}")
|
||||
else:
|
||||
print(f" FAIL: {name} {detail}")
|
||||
failures.append(name)
|
||||
|
||||
class PathAnimator:
|
||||
"""Handles step-by-step path animation with proper chaining"""
|
||||
|
||||
|
||||
def __init__(self, entity, path, step_duration=0.3, on_complete=None):
|
||||
self.entity = entity
|
||||
self.path = path
|
||||
|
|
@ -19,63 +34,67 @@ class PathAnimator:
|
|||
self.step_duration = step_duration
|
||||
self.on_complete = on_complete
|
||||
self.animating = False
|
||||
self.check_timer_name = f"path_check_{id(self)}"
|
||||
|
||||
self.completed_steps = [] # (index, draw_pos) recorded as each step lands
|
||||
self.overlaps = 0 # times a new step started while one was in flight
|
||||
self.anim_x = None
|
||||
self.anim_y = None
|
||||
|
||||
def start(self):
|
||||
"""Start animating along the path"""
|
||||
if not self.path or self.animating:
|
||||
return
|
||||
|
||||
|
||||
self.current_index = 0
|
||||
self.animating = True
|
||||
self._animate_next_step()
|
||||
|
||||
|
||||
def _animate_next_step(self):
|
||||
"""Animate to the next position in the path"""
|
||||
if self.current_index >= len(self.path):
|
||||
# Path complete
|
||||
self.animating = False
|
||||
if hasattr(self, '_check_timer'):
|
||||
self._check_timer.stop()
|
||||
if self.on_complete:
|
||||
self.on_complete()
|
||||
return
|
||||
|
||||
# Detect a chaining violation: the previous step must be finished
|
||||
if self.anim_x is not None and not self.anim_x.is_complete:
|
||||
self.overlaps += 1
|
||||
if self.anim_y is not None and not self.anim_y.is_complete:
|
||||
self.overlaps += 1
|
||||
|
||||
# Get target position
|
||||
target_x, target_y = self.path[self.current_index]
|
||||
|
||||
# Create animations
|
||||
self.anim_x = mcrfpy.Animation("x", float(target_x), self.step_duration, "easeInOut")
|
||||
self.anim_y = mcrfpy.Animation("y", float(target_y), self.step_duration, "easeInOut")
|
||||
|
||||
# Start animations
|
||||
self.anim_x.start(self.entity)
|
||||
self.anim_y.start(self.entity)
|
||||
# Create + start animations ('x'/'y' animate the entity's draw position,
|
||||
# in tile coordinates). The x animation's callback chains the next step.
|
||||
self.anim_y = self.entity.animate("y", float(target_y), self.step_duration,
|
||||
mcrfpy.Easing.EASE_IN_OUT)
|
||||
self.anim_x = self.entity.animate("x", float(target_x), self.step_duration,
|
||||
mcrfpy.Easing.EASE_IN_OUT,
|
||||
callback=self._on_step_complete)
|
||||
|
||||
# Update visibility if entity has this method
|
||||
if hasattr(self.entity, 'update_visibility'):
|
||||
self.entity.update_visibility()
|
||||
|
||||
# Set timer to check completion
|
||||
self._check_timer = mcrfpy.Timer(self.check_timer_name, self._check_completion, 50)
|
||||
|
||||
def _check_completion(self, timer, runtime):
|
||||
"""Check if current animation is complete"""
|
||||
if hasattr(self.anim_x, 'is_complete') and self.anim_x.is_complete:
|
||||
# Move to next step
|
||||
self.current_index += 1
|
||||
timer.stop()
|
||||
self._animate_next_step()
|
||||
def _on_step_complete(self, target, prop, value):
|
||||
"""Animation completion callback -- advance to the next path node"""
|
||||
self.completed_steps.append((self.current_index,
|
||||
(self.entity.draw_pos.x, self.entity.draw_pos.y)))
|
||||
self.current_index += 1
|
||||
self._animate_next_step()
|
||||
|
||||
# Create test scene
|
||||
chain_test = mcrfpy.Scene("chain_test")
|
||||
|
||||
# Create grid
|
||||
grid = mcrfpy.Grid(grid_w=20, grid_h=15)
|
||||
grid = mcrfpy.Grid(grid_size=(20, 15), pos=(100, 100), size=(600, 450))
|
||||
grid.fill_color = mcrfpy.Color(20, 20, 30)
|
||||
|
||||
# Add a color layer for cell coloring
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
# Add a color layer for cell coloring (GridPoint has no .color any more)
|
||||
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||
grid.add_layer(color_layer)
|
||||
|
||||
# Simple map
|
||||
for y in range(15):
|
||||
|
|
@ -84,33 +103,29 @@ for y in range(15):
|
|||
if x == 0 or x == 19 or y == 0 or y == 14:
|
||||
cell.walkable = False
|
||||
cell.transparent = False
|
||||
color_layer.set(x, y, mcrfpy.Color(60, 40, 40))
|
||||
color_layer.set((x, y), mcrfpy.Color(60, 40, 40))
|
||||
else:
|
||||
cell.walkable = True
|
||||
cell.transparent = True
|
||||
color_layer.set(x, y, mcrfpy.Color(100, 100, 120))
|
||||
color_layer.set((x, y), mcrfpy.Color(100, 100, 120))
|
||||
|
||||
# Create entities
|
||||
player = mcrfpy.Entity((2, 2), grid=grid)
|
||||
player = mcrfpy.Entity(grid_pos=(2, 2))
|
||||
player.sprite_index = 64 # @
|
||||
grid.entities.append(player)
|
||||
|
||||
enemy = mcrfpy.Entity((17, 12), grid=grid)
|
||||
enemy = mcrfpy.Entity(grid_pos=(17, 12))
|
||||
enemy.sprite_index = 69 # E
|
||||
grid.entities.append(enemy)
|
||||
|
||||
# UI setup
|
||||
ui = chain_test.children
|
||||
ui.append(grid)
|
||||
grid.pos = (100, 100)
|
||||
grid.size = (600, 450)
|
||||
|
||||
title = mcrfpy.Caption(pos=(300, 20), text="Animation Chaining Test")
|
||||
title.fill_color = mcrfpy.Color(255, 255, 255)
|
||||
ui.append(title)
|
||||
|
||||
status = mcrfpy.Caption(pos=(100, 50), text="Press 1: Animate Player | 2: Animate Enemy | 3: Both | Q: Quit")
|
||||
status.fill_color = mcrfpy.Color(200, 200, 200)
|
||||
ui.append(status)
|
||||
|
||||
info = mcrfpy.Caption(pos=(100, 70), text="Status: Ready")
|
||||
info.fill_color = mcrfpy.Color(100, 255, 100)
|
||||
ui.append(info)
|
||||
|
|
@ -118,42 +133,48 @@ ui.append(info)
|
|||
# Path animators
|
||||
player_animator = None
|
||||
enemy_animator = None
|
||||
player_done = False
|
||||
enemy_done = False
|
||||
|
||||
PLAYER_PATH = [
|
||||
(2, 2), (3, 2), (4, 2), (5, 2), (6, 2), # Right
|
||||
(6, 3), (6, 4), (6, 5), (6, 6), # Down
|
||||
(7, 6), (8, 6), (9, 6), (10, 6), # Right
|
||||
(10, 7), (10, 8), (10, 9), # Down
|
||||
]
|
||||
|
||||
ENEMY_PATH = [
|
||||
(17, 12), (16, 12), (15, 12), (14, 12), # Left
|
||||
(14, 11), (14, 10), (14, 9), # Up
|
||||
(13, 9), (12, 9), (11, 9), (10, 9), # Left
|
||||
(10, 8), (10, 7), (10, 6), # Up
|
||||
]
|
||||
|
||||
def animate_player():
|
||||
"""Animate player along a path"""
|
||||
global player_animator
|
||||
|
||||
# Define path
|
||||
path = [
|
||||
(2, 2), (3, 2), (4, 2), (5, 2), (6, 2), # Right
|
||||
(6, 3), (6, 4), (6, 5), (6, 6), # Down
|
||||
(7, 6), (8, 6), (9, 6), (10, 6), # Right
|
||||
(10, 7), (10, 8), (10, 9), # Down
|
||||
]
|
||||
|
||||
|
||||
def on_complete():
|
||||
global player_done
|
||||
player_done = True
|
||||
info.text = "Player animation complete!"
|
||||
|
||||
player_animator = PathAnimator(player, path, step_duration=0.2, on_complete=on_complete)
|
||||
|
||||
player_animator = PathAnimator(player, PLAYER_PATH, step_duration=0.2,
|
||||
on_complete=on_complete)
|
||||
player_animator.start()
|
||||
info.text = "Animating player..."
|
||||
|
||||
def animate_enemy():
|
||||
"""Animate enemy along a path"""
|
||||
global enemy_animator
|
||||
|
||||
# Define path
|
||||
path = [
|
||||
(17, 12), (16, 12), (15, 12), (14, 12), # Left
|
||||
(14, 11), (14, 10), (14, 9), # Up
|
||||
(13, 9), (12, 9), (11, 9), (10, 9), # Left
|
||||
(10, 8), (10, 7), (10, 6), # Up
|
||||
]
|
||||
|
||||
|
||||
def on_complete():
|
||||
global enemy_done
|
||||
enemy_done = True
|
||||
info.text = "Enemy animation complete!"
|
||||
|
||||
enemy_animator = PathAnimator(enemy, path, step_duration=0.25, on_complete=on_complete)
|
||||
|
||||
enemy_animator = PathAnimator(enemy, ENEMY_PATH, step_duration=0.25,
|
||||
on_complete=on_complete)
|
||||
enemy_animator.start()
|
||||
info.text = "Animating enemy..."
|
||||
|
||||
|
|
@ -164,60 +185,101 @@ def animate_both():
|
|||
animate_enemy()
|
||||
|
||||
# Camera follow test
|
||||
camera_follow = False
|
||||
camera_follow = True
|
||||
camera_updates = 0
|
||||
|
||||
def update_camera(timer, runtime):
|
||||
"""Update camera to follow player if enabled"""
|
||||
global camera_updates
|
||||
if camera_follow and player_animator and player_animator.animating:
|
||||
# Smooth camera follow
|
||||
center_x = player.x * 30 # Assuming ~30 pixels per cell
|
||||
center_y = player.y * 30
|
||||
cam_anim = mcrfpy.Animation("center", (center_x, center_y), 0.25, "linear")
|
||||
cam_anim.start(grid)
|
||||
|
||||
# Input handler
|
||||
def handle_input(key, state):
|
||||
global camera_follow
|
||||
|
||||
if state != mcrfpy.InputState.PRESSED:
|
||||
return
|
||||
|
||||
if key == mcrfpy.Key.Q:
|
||||
sys.exit(0)
|
||||
elif key == mcrfpy.Key.NUM_1:
|
||||
animate_player()
|
||||
elif key == mcrfpy.Key.NUM_2:
|
||||
animate_enemy()
|
||||
elif key == mcrfpy.Key.NUM_3:
|
||||
animate_both()
|
||||
elif key == mcrfpy.Key.C:
|
||||
camera_follow = not camera_follow
|
||||
info.text = f"Camera follow: {'ON' if camera_follow else 'OFF'}"
|
||||
elif key == mcrfpy.Key.R:
|
||||
# Reset positions
|
||||
player.x, player.y = 2, 2
|
||||
enemy.x, enemy.y = 17, 12
|
||||
info.text = "Positions reset"
|
||||
# Smooth camera follow. grid.center is in pixels; entity.x/.y are the
|
||||
# entity's draw position in pixels (draw_pos is the same in tile coords).
|
||||
grid.animate("center", (player.x, player.y), 0.25, mcrfpy.Easing.LINEAR)
|
||||
camera_updates += 1
|
||||
|
||||
# Setup
|
||||
chain_test.activate()
|
||||
chain_test.on_key = handle_input
|
||||
|
||||
# Camera update timer
|
||||
cam_update_timer = mcrfpy.Timer("cam_update", update_camera, 100)
|
||||
|
||||
print("Animation Chaining Test")
|
||||
print("=======================")
|
||||
print("This test demonstrates proper animation chaining")
|
||||
print("to avoid simultaneous position updates.")
|
||||
print()
|
||||
print("Controls:")
|
||||
print(" 1 - Animate player step by step")
|
||||
print(" 2 - Animate enemy step by step")
|
||||
print(" 3 - Animate both (simultaneous)")
|
||||
print(" C - Toggle camera follow")
|
||||
print(" R - Reset positions")
|
||||
print(" Q - Quit")
|
||||
print()
|
||||
print("Notice how each entity moves one tile at a time,")
|
||||
print("waiting for each step to complete before the next.")
|
||||
|
||||
# --- Drive the chained animations headlessly -------------------------------
|
||||
animate_both()
|
||||
|
||||
check("player animator started", player_animator.animating)
|
||||
check("enemy animator started", enemy_animator.animating)
|
||||
|
||||
# Path node 0 is the entity's starting cell, so the first 0.2s step is a no-op
|
||||
# move. Halfway through the *second* step the entity must be strictly between
|
||||
# nodes 0 and 1 -- i.e. the animation is interpolating, one step at a time.
|
||||
for _ in range(2):
|
||||
mcrfpy.step(0.1) # completes step 0, chains step 1
|
||||
check("first step completed before the next began",
|
||||
player_animator.current_index == 1 and len(player_animator.completed_steps) == 1,
|
||||
f"(index={player_animator.current_index})")
|
||||
|
||||
mcrfpy.step(0.1) # halfway through step 1: (2,2) -> (3,2)
|
||||
mid_x = player.draw_pos.x
|
||||
check("player interpolates between tiles", 2.0 < mid_x < 3.0,
|
||||
f"(draw_x={mid_x})")
|
||||
check("only one step in flight at a time (mid-step)",
|
||||
player_animator.current_index == 1,
|
||||
f"(index={player_animator.current_index})")
|
||||
|
||||
# Player: 16 nodes * 0.2s; enemy: 14 nodes * 0.25s -> 3.5s worst case.
|
||||
elapsed = 0.3
|
||||
while elapsed < 6.0 and not (player_done and enemy_done):
|
||||
mcrfpy.step(0.05)
|
||||
elapsed += 0.05
|
||||
|
||||
# --- Assertions ------------------------------------------------------------
|
||||
check("player path completed", player_done)
|
||||
check("enemy path completed", enemy_done)
|
||||
check("player animator stopped", not player_animator.animating)
|
||||
check("enemy animator stopped", not enemy_animator.animating)
|
||||
|
||||
check("no overlapping player animations", player_animator.overlaps == 0,
|
||||
f"(overlaps={player_animator.overlaps})")
|
||||
check("no overlapping enemy animations", enemy_animator.overlaps == 0,
|
||||
f"(overlaps={enemy_animator.overlaps})")
|
||||
|
||||
check("player visited every path node",
|
||||
[i for i, _ in player_animator.completed_steps] == list(range(len(PLAYER_PATH))),
|
||||
f"({player_animator.completed_steps})")
|
||||
check("enemy visited every path node",
|
||||
[i for i, _ in enemy_animator.completed_steps] == list(range(len(ENEMY_PATH))),
|
||||
f"({enemy_animator.completed_steps})")
|
||||
|
||||
# Each step landed exactly on its path node (chaining kept positions in sync)
|
||||
player_landings_ok = all(pos == (float(px), float(py))
|
||||
for (i, pos), (px, py)
|
||||
in zip(player_animator.completed_steps, PLAYER_PATH))
|
||||
check("each player step landed on its path node", player_landings_ok,
|
||||
f"({player_animator.completed_steps})")
|
||||
|
||||
enemy_landings_ok = all(pos == (float(px), float(py))
|
||||
for (i, pos), (px, py)
|
||||
in zip(enemy_animator.completed_steps, ENEMY_PATH))
|
||||
check("each enemy step landed on its path node", enemy_landings_ok,
|
||||
f"({enemy_animator.completed_steps})")
|
||||
|
||||
check("player ended at final path node",
|
||||
(player.draw_pos.x, player.draw_pos.y) == (float(PLAYER_PATH[-1][0]),
|
||||
float(PLAYER_PATH[-1][1])),
|
||||
f"({player.draw_pos})")
|
||||
check("enemy ended at final path node",
|
||||
(enemy.draw_pos.x, enemy.draw_pos.y) == (float(ENEMY_PATH[-1][0]),
|
||||
float(ENEMY_PATH[-1][1])),
|
||||
f"({enemy.draw_pos})")
|
||||
|
||||
# Camera-follow timer ran while the player was animating
|
||||
check("camera follow timer fired during animation", camera_updates > 0,
|
||||
f"(updates={camera_updates})")
|
||||
|
||||
if failures:
|
||||
print(f"FAIL: {len(failures)} check(s) failed: {failures}")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -33,9 +33,8 @@ for i in range(10):
|
|||
ui.append(f)
|
||||
initial_frames.append(f)
|
||||
|
||||
# Animate them
|
||||
anim = mcrfpy.Animation("y", 300.0, 2.0, "easeOutBounce")
|
||||
anim.start(f)
|
||||
# Animate them (mcrfpy.Animation is gone; animations are built by the target)
|
||||
f.animate("y", 300.0, 2.0, mcrfpy.Easing.EASE_OUT_BOUNCE)
|
||||
|
||||
print(f"Initial scene has {len(ui)} elements")
|
||||
|
||||
|
|
@ -55,6 +54,7 @@ print(f"Scene has {len(ui)} elements after clearing")
|
|||
|
||||
# Create new animated objects
|
||||
print("Creating new animated objects...")
|
||||
new_frames = []
|
||||
for i in range(5):
|
||||
f = mcrfpy.Frame(pos=(100 + i*50, 200), size=(40, 40))
|
||||
f.fill_color = mcrfpy.Color(100 + i*30, 50, 200)
|
||||
|
|
@ -62,20 +62,34 @@ for i in range(5):
|
|||
|
||||
# Start animation on the new frame
|
||||
target_x = 300 + i * 50
|
||||
anim = mcrfpy.Animation("x", float(target_x), 1.0, "easeInOut")
|
||||
anim.start(f)
|
||||
f.animate("x", float(target_x), 1.0, mcrfpy.Easing.EASE_IN_OUT)
|
||||
new_frames.append((f, float(target_x)))
|
||||
|
||||
print("New objects created and animated")
|
||||
print(f"Scene now has {len(ui)} elements")
|
||||
|
||||
# Let new animations run
|
||||
mcrfpy.step(1.5)
|
||||
# Let new animations run to completion (duration 1.0s)
|
||||
for _ in range(20):
|
||||
mcrfpy.step(0.1)
|
||||
|
||||
# Final check
|
||||
failures = []
|
||||
|
||||
# Final check: element count survived the clear/recreate cycle
|
||||
print(f"\nFinal scene has {len(ui)} elements")
|
||||
if len(ui) == 7: # 2 captions + 5 new frames
|
||||
print("SUCCESS: Animation removal test passed!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(f"FAIL: Expected 7 elements, got {len(ui)}")
|
||||
if len(ui) != 7: # 2 captions + 5 new frames
|
||||
failures.append(f"Expected 7 elements, got {len(ui)}")
|
||||
|
||||
# The removed frames' animations must not have kept running (or crashed);
|
||||
# the new frames' animations must have actually completed.
|
||||
for i, (f, target_x) in enumerate(new_frames):
|
||||
if abs(f.x - target_x) > 0.5:
|
||||
failures.append(f"new frame {i}: x={f.x}, expected {target_x}")
|
||||
|
||||
if failures:
|
||||
for msg in failures:
|
||||
print(f"FAIL: {msg}")
|
||||
sys.exit(1)
|
||||
|
||||
print("SUCCESS: Animation removal test passed!")
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,164 +1,164 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test that API documentation generator works correctly."""
|
||||
"""Test that API documentation generator works correctly.
|
||||
|
||||
The hand-written docs/API_REFERENCE.md is gone; the canonical API reference is now
|
||||
docs/API_REFERENCE_DYNAMIC.md, generated from the compiled module by
|
||||
tools/generate_dynamic_docs.py (MCRF_* docstring macros -> introspection -> markdown).
|
||||
This test verifies that generated artifact exists, is well-formed, and stays in sync
|
||||
with the live mcrfpy module.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# tests/unit/test_api_docs.py -> repo root -> docs/
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
DOCS_PATH = REPO_ROOT / "docs" / "API_REFERENCE_DYNAMIC.md"
|
||||
|
||||
|
||||
def test_api_docs_exist():
|
||||
"""Test that API documentation was generated."""
|
||||
docs_path = Path("docs/API_REFERENCE.md")
|
||||
|
||||
if not docs_path.exists():
|
||||
print("ERROR: API documentation not found at docs/API_REFERENCE.md")
|
||||
if not DOCS_PATH.exists():
|
||||
print(f"ERROR: API documentation not found at {DOCS_PATH}")
|
||||
return False
|
||||
|
||||
print("✓ API documentation file exists")
|
||||
|
||||
|
||||
print("+ API documentation file exists")
|
||||
|
||||
# Check file size
|
||||
size = docs_path.stat().st_size
|
||||
size = DOCS_PATH.stat().st_size
|
||||
if size < 1000:
|
||||
print(f"ERROR: API documentation seems too small ({size} bytes)")
|
||||
return False
|
||||
|
||||
print(f"✓ API documentation has reasonable size ({size} bytes)")
|
||||
|
||||
|
||||
print(f"+ API documentation has reasonable size ({size} bytes)")
|
||||
|
||||
# Read content
|
||||
with open(docs_path, 'r') as f:
|
||||
with open(DOCS_PATH, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for expected sections
|
||||
|
||||
# Check for expected sections (current generated layout)
|
||||
expected_sections = [
|
||||
"# McRogueFace API Reference",
|
||||
"## Overview",
|
||||
"## Classes",
|
||||
"## Table of Contents",
|
||||
"## Module Attributes",
|
||||
"## Functions",
|
||||
"## Automation Module"
|
||||
"## Classes",
|
||||
]
|
||||
|
||||
missing = []
|
||||
for section in expected_sections:
|
||||
if section not in content:
|
||||
missing.append(section)
|
||||
|
||||
|
||||
missing = [s for s in expected_sections if s not in content]
|
||||
if missing:
|
||||
print(f"ERROR: Missing sections: {missing}")
|
||||
return False
|
||||
|
||||
print("✓ All expected sections present")
|
||||
|
||||
# Check for key classes
|
||||
|
||||
print("+ All expected sections present")
|
||||
|
||||
# Check for key classes ("### ClassName" headings)
|
||||
key_classes = ["Frame", "Caption", "Sprite", "Grid", "Entity", "Scene"]
|
||||
missing_classes = []
|
||||
for cls in key_classes:
|
||||
if f"### class {cls}" not in content:
|
||||
missing_classes.append(cls)
|
||||
|
||||
missing_classes = [c for c in key_classes if f"\n### {c}\n" not in content]
|
||||
if missing_classes:
|
||||
print(f"ERROR: Missing classes: {missing_classes}")
|
||||
return False
|
||||
|
||||
print("✓ All key classes documented")
|
||||
|
||||
# Check for key functions
|
||||
key_functions = ["createScene", "setScene", "currentScene", "find", "setTimer"]
|
||||
missing_funcs = []
|
||||
for func in key_functions:
|
||||
if f"### {func}" not in content:
|
||||
missing_funcs.append(func)
|
||||
|
||||
|
||||
print("+ All key classes documented")
|
||||
|
||||
# Check for key module-level functions ("### `name(...)`" headings)
|
||||
key_functions = ["find", "find_all", "step", "get_metrics", "set_scale", "exit"]
|
||||
missing_funcs = [f for f in key_functions if f"\n### `{f}(" not in content]
|
||||
if missing_funcs:
|
||||
print(f"ERROR: Missing functions: {missing_funcs}")
|
||||
return False
|
||||
|
||||
print("✓ All key functions documented")
|
||||
|
||||
# Check automation module
|
||||
if "automation.screenshot" in content:
|
||||
print("✓ Automation module documented")
|
||||
else:
|
||||
print("ERROR: Automation module not properly documented")
|
||||
|
||||
print("+ All key functions documented")
|
||||
|
||||
# Scene management moved from functions to the mcrfpy.current_scene attribute
|
||||
if "### `mcrfpy.current_scene`" not in content:
|
||||
print("ERROR: mcrfpy.current_scene not documented under Module Attributes")
|
||||
return False
|
||||
|
||||
|
||||
print("+ Module attributes documented")
|
||||
|
||||
# Count documentation entries
|
||||
class_count = content.count("### class ")
|
||||
func_count = content.count("### ") - class_count - content.count("### automation.")
|
||||
auto_count = content.count("### automation.")
|
||||
|
||||
class_count = sum(1 for line in content.splitlines()
|
||||
if line.startswith("### ") and not line.startswith("### `"))
|
||||
func_count = sum(1 for line in content.splitlines() if line.startswith("### `"))
|
||||
member_count = sum(1 for line in content.splitlines() if line.startswith("#### "))
|
||||
|
||||
print(f"\nDocumentation Coverage:")
|
||||
print(f"- Classes: {class_count}")
|
||||
print(f"- Functions: {func_count}")
|
||||
print(f"- Automation methods: {auto_count}")
|
||||
|
||||
print(f"- Functions/attributes: {func_count}")
|
||||
print(f"- Class members: {member_count}")
|
||||
|
||||
if class_count == 0 or func_count == 0 or member_count == 0:
|
||||
print("ERROR: documentation contains no entries")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_doc_accuracy():
|
||||
"""Test that documentation matches actual API."""
|
||||
# Import mcrfpy to check
|
||||
import mcrfpy
|
||||
|
||||
|
||||
print("\nVerifying documentation accuracy...")
|
||||
|
||||
# Read documentation
|
||||
with open("docs/API_REFERENCE.md", 'r') as f:
|
||||
|
||||
with open(DOCS_PATH, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
|
||||
passed = True
|
||||
|
||||
# Check that all public classes are documented
|
||||
actual_classes = [name for name in dir(mcrfpy)
|
||||
actual_classes = [name for name in dir(mcrfpy)
|
||||
if isinstance(getattr(mcrfpy, name), type) and not name.startswith('_')]
|
||||
|
||||
undocumented = []
|
||||
for cls in actual_classes:
|
||||
if f"### class {cls}" not in content:
|
||||
undocumented.append(cls)
|
||||
|
||||
|
||||
undocumented = [c for c in actual_classes if f"\n### {c}\n" not in content]
|
||||
if undocumented:
|
||||
print(f"WARNING: Undocumented classes: {undocumented}")
|
||||
print(f"ERROR: Undocumented classes: {undocumented}")
|
||||
passed = False
|
||||
else:
|
||||
print("✓ All public classes are documented")
|
||||
|
||||
print(f"+ All {len(actual_classes)} public classes are documented")
|
||||
|
||||
# Check functions
|
||||
actual_funcs = [name for name in dir(mcrfpy)
|
||||
if callable(getattr(mcrfpy, name)) and not name.startswith('_')
|
||||
actual_funcs = [name for name in dir(mcrfpy)
|
||||
if callable(getattr(mcrfpy, name)) and not name.startswith('_')
|
||||
and not isinstance(getattr(mcrfpy, name), type)]
|
||||
|
||||
undoc_funcs = []
|
||||
for func in actual_funcs:
|
||||
if f"### {func}" not in content:
|
||||
undoc_funcs.append(func)
|
||||
|
||||
|
||||
undoc_funcs = [f for f in actual_funcs if f"\n### `{f}(" not in content]
|
||||
if undoc_funcs:
|
||||
print(f"WARNING: Undocumented functions: {undoc_funcs}")
|
||||
print(f"ERROR: Undocumented functions: {undoc_funcs}")
|
||||
passed = False
|
||||
else:
|
||||
print("✓ All public functions are documented")
|
||||
|
||||
return True
|
||||
print(f"+ All {len(actual_funcs)} public functions are documented")
|
||||
|
||||
return passed
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all API documentation tests."""
|
||||
print("API Documentation Tests")
|
||||
print("======================\n")
|
||||
|
||||
|
||||
all_passed = True
|
||||
|
||||
|
||||
# Test 1: Documentation exists and is complete
|
||||
print("Test 1: Documentation Generation")
|
||||
if not test_api_docs_exist():
|
||||
all_passed = False
|
||||
print()
|
||||
|
||||
|
||||
# Test 2: Documentation accuracy
|
||||
print("Test 2: Documentation Accuracy")
|
||||
if not test_doc_accuracy():
|
||||
all_passed = False
|
||||
print()
|
||||
|
||||
|
||||
if all_passed:
|
||||
print("✅ All API documentation tests passed!")
|
||||
print("PASS: All API documentation tests passed!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("❌ Some tests failed.")
|
||||
print("FAIL: Some tests failed.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,15 @@
|
|||
Test A* Pathfinding Implementation
|
||||
==================================
|
||||
|
||||
Compares A* with Dijkstra and the existing find_path method.
|
||||
Compares A* (GridData.find_path) with Dijkstra (GridData.get_dijkstra_map)
|
||||
and verifies path validity around obstacles.
|
||||
|
||||
API notes (current contract):
|
||||
- The old grid.compute_astar_path/compute_dijkstra/get_dijkstra_path methods are gone.
|
||||
A* is now GridData.find_path(start, end, ...) -> AStarPath | None
|
||||
Dijkstra is now GridData.get_dijkstra_map(root=...) -> DijkstraMap (.path_from/.distance)
|
||||
- Pathfinding lives on GridData, not on the Grid view (mcrfpy.Grid is a GridView).
|
||||
- Headless has no automatic clock: timers only fire from mcrfpy.step().
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
|
|
@ -13,24 +21,42 @@ import time
|
|||
print("A* Pathfinding Test")
|
||||
print("==================")
|
||||
|
||||
failures = []
|
||||
|
||||
def check(cond, msg):
|
||||
if cond:
|
||||
print(f" PASS: {msg}")
|
||||
else:
|
||||
print(f" FAIL: {msg}")
|
||||
failures.append(msg)
|
||||
|
||||
# Create scene and grid
|
||||
astar_test = mcrfpy.Scene("astar_test")
|
||||
grid = mcrfpy.Grid(grid_w=20, grid_h=20)
|
||||
grid = mcrfpy.Grid(grid_size=(20, 20), pos=(50, 50), size=(400, 400))
|
||||
data = grid.grid_data # pathfinding lives on GridData
|
||||
|
||||
# Initialize grid - all walkable
|
||||
for y in range(20):
|
||||
for x in range(20):
|
||||
grid.at(x, y).walkable = True
|
||||
data.at(x, y).walkable = True
|
||||
|
||||
# Create a wall barrier with a narrow passage
|
||||
# Create a wall barrier with a narrow passage.
|
||||
# The barrier is 4 cells thick (x 8..11) and spans the full grid height, with a
|
||||
# 1-cell-tall corridor carved through it at y == 10. Two corrections vs. the
|
||||
# original test: (a) a partial-height wall could simply be rounded diagonally for
|
||||
# the same cost, and (b) a single free cell inside a 4-thick wall is not a passage
|
||||
# at all -- every neighbour is wall, so it can never be entered.
|
||||
print("\nCreating wall with narrow passage...")
|
||||
for y in range(5, 15):
|
||||
walls = set()
|
||||
for y in range(20):
|
||||
for x in range(8, 12):
|
||||
if not (x == 10 and y == 10): # Leave a gap at (10, 10)
|
||||
grid.at(x, y).walkable = False
|
||||
print(f" Wall at ({x}, {y})")
|
||||
if y == 10: # corridor through the barrier, including (10, 10)
|
||||
continue
|
||||
data.at(x, y).walkable = False
|
||||
walls.add((x, y))
|
||||
|
||||
print(f"\nPassage at (10, 10)")
|
||||
print(f"Wall cells: {len(walls)}, passage at (10, 10)")
|
||||
data.clear_dijkstra_maps()
|
||||
|
||||
# Test points
|
||||
start = (2, 10)
|
||||
|
|
@ -38,93 +64,125 @@ end = (18, 10)
|
|||
|
||||
print(f"\nFinding path from {start} to {end}")
|
||||
|
||||
# Test 1: A* pathfinding
|
||||
print("\n1. Testing A* pathfinding (compute_astar_path):")
|
||||
start_time = time.time()
|
||||
astar_path = grid.compute_astar_path(start[0], start[1], end[0], end[1])
|
||||
astar_time = time.time() - start_time
|
||||
print(f" A* path length: {len(astar_path)}")
|
||||
print(f" A* time: {astar_time*1000:.3f} ms")
|
||||
if astar_path:
|
||||
print(f" First 5 steps: {astar_path[:5]}")
|
||||
|
||||
# Test 2: find_path method (which should also use A*)
|
||||
print("\n2. Testing find_path method:")
|
||||
def as_cells(path):
|
||||
return [(int(v.x), int(v.y)) for v in path]
|
||||
|
||||
|
||||
def is_contiguous(cells):
|
||||
for a, b in zip(cells, cells[1:]):
|
||||
if max(abs(a[0] - b[0]), abs(a[1] - b[1])) != 1:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# Test 1: A* pathfinding
|
||||
print("\n1. Testing A* pathfinding (find_path):")
|
||||
start_time = time.time()
|
||||
find_path_result = grid.find_path(start[0], start[1], end[0], end[1])
|
||||
find_path_time = time.time() - start_time
|
||||
print(f" find_path length: {len(find_path_result)}")
|
||||
print(f" find_path time: {find_path_time*1000:.3f} ms")
|
||||
if find_path_result:
|
||||
print(f" First 5 steps: {find_path_result[:5]}")
|
||||
astar_path = data.find_path(start, end)
|
||||
astar_time = time.time() - start_time
|
||||
check(astar_path is not None, "A* found a path through the barrier")
|
||||
# NOTE: iterating an AStarPath consumes it, so read .remaining before as_cells().
|
||||
astar_remaining = astar_path.remaining if astar_path else 0
|
||||
astar_cells = as_cells(astar_path) if astar_path else []
|
||||
print(f" A* path length: {len(astar_cells)}")
|
||||
print(f" A* time: {astar_time*1000:.3f} ms")
|
||||
print(f" First 5 steps: {astar_cells[:5]}")
|
||||
check(bool(astar_cells) and astar_cells[-1] == end, "A* path terminates at the destination")
|
||||
check(not (walls & set(astar_cells)), "A* path never enters a wall cell")
|
||||
check(is_contiguous([start] + astar_cells), "A* path is contiguous from the start cell")
|
||||
check((10, 10) in astar_cells, "A* path uses the narrow passage at (10, 10)")
|
||||
|
||||
# Test 2: A* path costs/endpoints via the AStarPath object
|
||||
print("\n2. Testing AStarPath object:")
|
||||
check(as_cells([astar_path.origin])[0] == start, "AStarPath.origin is the start cell")
|
||||
check(as_cells([astar_path.destination])[0] == end, "AStarPath.destination is the end cell")
|
||||
check(astar_remaining == len(astar_cells), "AStarPath.remaining matches path length")
|
||||
check(astar_path.remaining == 0, "AStarPath is consumed once fully walked/iterated")
|
||||
|
||||
# Test 3: Dijkstra pathfinding for comparison
|
||||
print("\n3. Testing Dijkstra pathfinding:")
|
||||
start_time = time.time()
|
||||
grid.compute_dijkstra(start[0], start[1])
|
||||
dijkstra_path = grid.get_dijkstra_path(end[0], end[1])
|
||||
dmap = data.get_dijkstra_map(root=start)
|
||||
dijkstra_path = dmap.path_from(end)
|
||||
dijkstra_time = time.time() - start_time
|
||||
print(f" Dijkstra path length: {len(dijkstra_path)}")
|
||||
dijkstra_cells = as_cells(dijkstra_path) if dijkstra_path else []
|
||||
print(f" Dijkstra path length: {len(dijkstra_cells)}")
|
||||
print(f" Dijkstra time: {dijkstra_time*1000:.3f} ms")
|
||||
if dijkstra_path:
|
||||
print(f" First 5 steps: {dijkstra_path[:5]}")
|
||||
print(f" First 5 steps: {dijkstra_cells[:5]}")
|
||||
check(bool(dijkstra_cells), "Dijkstra found a path back to the root")
|
||||
check(not (walls & set(dijkstra_cells)), "Dijkstra path never enters a wall cell")
|
||||
check((10, 10) in dijkstra_cells, "Dijkstra path uses the narrow passage at (10, 10)")
|
||||
check(dmap.distance(end) is not None, "Dijkstra distance to the destination is defined")
|
||||
|
||||
# Compare results
|
||||
# Compare results - both are optimal, so step counts must agree
|
||||
print("\nComparison:")
|
||||
print(f" A* vs find_path: {'SAME' if astar_path == find_path_result else 'DIFFERENT'}")
|
||||
print(f" A* vs Dijkstra: {'SAME' if astar_path == dijkstra_path else 'DIFFERENT'}")
|
||||
print(f" A* steps: {len(astar_cells)}, Dijkstra steps: {len(dijkstra_cells)}")
|
||||
check(len(astar_cells) == len(dijkstra_cells),
|
||||
"A* and Dijkstra agree on optimal step count")
|
||||
|
||||
# Test with no path (blocked endpoints)
|
||||
# Test 4: no path (blocked destination inside the wall)
|
||||
print("\n4. Testing with blocked destination:")
|
||||
blocked_end = (10, 8) # Inside the wall
|
||||
grid.at(blocked_end[0], blocked_end[1]).walkable = False
|
||||
no_path = grid.compute_astar_path(start[0], start[1], blocked_end[0], blocked_end[1])
|
||||
print(f" Path to blocked cell: {no_path} (should be empty)")
|
||||
blocked_end = (10, 8) # inside the wall
|
||||
check(data.at(*blocked_end).walkable is False, "blocked destination is unwalkable")
|
||||
no_path = data.find_path(start, blocked_end)
|
||||
print(f" Path to blocked cell: {no_path}")
|
||||
check(no_path is None or len(no_path) == 0, "no path is returned for a blocked destination")
|
||||
|
||||
# Test diagonal movement
|
||||
# Test 5: diagonal movement
|
||||
print("\n5. Testing diagonal paths:")
|
||||
diag_start = (0, 0)
|
||||
diag_end = (5, 5)
|
||||
diag_path = grid.compute_astar_path(diag_start[0], diag_start[1], diag_end[0], diag_end[1])
|
||||
print(f" Diagonal path from {diag_start} to {diag_end}:")
|
||||
print(f" Length: {len(diag_path)}")
|
||||
print(f" Path: {diag_path}")
|
||||
diag_path = data.find_path(diag_start, diag_end)
|
||||
diag_cells = as_cells(diag_path) if diag_path else []
|
||||
print(f" Diagonal path from {diag_start} to {diag_end}: {diag_cells}")
|
||||
# Optimal diagonal path is 5 moves (one diagonal step per cell)
|
||||
check(len(diag_cells) == 5, "diagonal path from (0,0) to (5,5) takes 5 steps")
|
||||
check(diag_cells and diag_cells[-1] == diag_end, "diagonal path reaches the destination")
|
||||
|
||||
# Expected optimal diagonal path length is 5 moves (moving diagonally each step)
|
||||
|
||||
# Performance test with larger path
|
||||
# Test 6: performance / corner-to-corner agreement
|
||||
print("\n6. Performance test (corner to corner):")
|
||||
corner_paths = []
|
||||
methods = [
|
||||
("A*", lambda: grid.compute_astar_path(0, 0, 19, 19)),
|
||||
("Dijkstra", lambda: (grid.compute_dijkstra(0, 0), grid.get_dijkstra_path(19, 19))[1])
|
||||
]
|
||||
results = {}
|
||||
start_time = time.time()
|
||||
corner_astar = as_cells(data.find_path((0, 0), (19, 19)) or [])
|
||||
results["A*"] = (len(corner_astar), time.time() - start_time)
|
||||
start_time = time.time()
|
||||
corner_dmap = data.get_dijkstra_map(root=(0, 0))
|
||||
corner_dijkstra = as_cells(corner_dmap.path_from((19, 19)) or [])
|
||||
results["Dijkstra"] = (len(corner_dijkstra), time.time() - start_time)
|
||||
for name, (steps, elapsed) in results.items():
|
||||
print(f" {name}: {steps} steps in {elapsed*1000:.3f} ms")
|
||||
check(len(corner_astar) > 0 and len(corner_dijkstra) > 0,
|
||||
"corner-to-corner path found by both algorithms")
|
||||
check(len(corner_astar) == len(corner_dijkstra),
|
||||
"corner-to-corner step counts agree between A* and Dijkstra")
|
||||
|
||||
for name, method in methods:
|
||||
start_time = time.time()
|
||||
path = method()
|
||||
elapsed = time.time() - start_time
|
||||
print(f" {name}: {len(path)} steps in {elapsed*1000:.3f} ms")
|
||||
# Quick smoke test that the grid renders in a scene and the clock advances.
|
||||
# Headless: mcrfpy.step() is the only clock; a Timer never fires on its own.
|
||||
timer_fired = []
|
||||
|
||||
print("\nA* pathfinding tests completed!")
|
||||
print("Summary:")
|
||||
print(" - A* pathfinding is working correctly")
|
||||
print(" - Paths match between A* and Dijkstra")
|
||||
print(" - Empty paths returned for blocked destinations")
|
||||
print(" - Diagonal movement supported")
|
||||
|
||||
# Quick visual test
|
||||
def visual_test(timer, runtime):
|
||||
print("\nVisual test timer fired")
|
||||
sys.exit(0)
|
||||
timer_fired.append(runtime)
|
||||
|
||||
|
||||
# Set up minimal UI for visual test
|
||||
ui = astar_test.children
|
||||
ui.append(grid)
|
||||
grid.pos = (50, 50)
|
||||
grid.size = (400, 400)
|
||||
|
||||
astar_test.activate()
|
||||
visual_test_timer = mcrfpy.Timer("visual", visual_test, 100, once=True)
|
||||
|
||||
print("\nStarting visual test...")
|
||||
print("\nStarting visual test...")
|
||||
for _ in range(10):
|
||||
mcrfpy.step(0.05)
|
||||
check(bool(timer_fired), "scene timer fired after stepping the headless clock")
|
||||
|
||||
print("\nA* pathfinding tests completed!")
|
||||
if failures:
|
||||
print(f"\nFAIL: {len(failures)} check(s) failed:")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,19 +1,40 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test Python builtins in function context like the failing demos"""
|
||||
"""Test Python builtins in function context like the failing demos
|
||||
|
||||
Regression guard: builtins (range, list.append, f-strings) must keep working at
|
||||
module level, inside functions, and -- critically -- after mcrfpy objects have
|
||||
been constructed. A broken embedded-interpreter builtins dict used to make
|
||||
`range()` blow up in exactly those spots.
|
||||
|
||||
Failures are RECORDED, not just printed: the script exits 1 if any check fails.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
failures = []
|
||||
|
||||
def fail(label, msg):
|
||||
failures.append(f"{label}: {msg}")
|
||||
print(f" x FAIL {label}: {msg}")
|
||||
|
||||
print("Testing builtins in different contexts...")
|
||||
print("=" * 50)
|
||||
|
||||
# Test 1: At module level (working in our test)
|
||||
# Test 1: At module level
|
||||
print("Test 1: Module level")
|
||||
try:
|
||||
xs = []
|
||||
for x in range(3):
|
||||
print(f" x={x}")
|
||||
print(" ✓ Module level works")
|
||||
xs.append(x)
|
||||
if xs != [0, 1, 2]:
|
||||
fail("module level range", f"expected [0, 1, 2], got {xs}")
|
||||
else:
|
||||
print(" ok Module level works")
|
||||
except Exception as e:
|
||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||
traceback.print_exc()
|
||||
fail("module level range", f"{type(e).__name__}: {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
|
@ -21,13 +42,17 @@ print()
|
|||
print("Test 2: Inside function")
|
||||
def test_function():
|
||||
try:
|
||||
xs = [x for x in range(3)]
|
||||
xs2 = []
|
||||
for x in range(3):
|
||||
print(f" x={x}")
|
||||
print(" ✓ Function level works")
|
||||
xs2.append(f"x={x}")
|
||||
if xs != [0, 1, 2] or xs2 != ["x=0", "x=1", "x=2"]:
|
||||
fail("function level builtins", f"got {xs}, {xs2}")
|
||||
return
|
||||
print(" ok Function level works")
|
||||
except Exception as e:
|
||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
fail("function level builtins", f"{type(e).__name__}: {e}")
|
||||
|
||||
test_function()
|
||||
|
||||
|
|
@ -38,30 +63,35 @@ print("Test 3: Function creating mcrfpy objects")
|
|||
def create_scene():
|
||||
try:
|
||||
test = mcrfpy.Scene("test")
|
||||
print(" ✓ Created scene")
|
||||
|
||||
# Now try range
|
||||
for x in range(3):
|
||||
print(f" x={x}")
|
||||
print(" ✓ Range after createScene works")
|
||||
|
||||
# Create grid
|
||||
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
|
||||
print(" ✓ Created grid")
|
||||
|
||||
# Try range again
|
||||
for x in range(3):
|
||||
print(f" x={x}")
|
||||
print(" ✓ Range after Grid creation works")
|
||||
|
||||
print(" ok Created scene")
|
||||
|
||||
# Now try range -- must still work after a Scene exists
|
||||
xs = [x for x in range(3)]
|
||||
if xs != [0, 1, 2]:
|
||||
fail("range after Scene()", f"expected [0, 1, 2], got {xs}")
|
||||
return None
|
||||
print(" ok Range after Scene creation works")
|
||||
|
||||
# Create grid (current API: grid_size tuple)
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
print(" ok Created grid")
|
||||
|
||||
# Try range again -- must still work after a Grid exists
|
||||
xs = [x for x in range(3)]
|
||||
if xs != [0, 1, 2]:
|
||||
fail("range after Grid()", f"expected [0, 1, 2], got {xs}")
|
||||
return None
|
||||
print(" ok Range after Grid creation works")
|
||||
|
||||
return grid
|
||||
except Exception as e:
|
||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
fail("mcrfpy object construction context", f"{type(e).__name__}: {e}")
|
||||
return None
|
||||
|
||||
grid = create_scene()
|
||||
if grid is None:
|
||||
fail("create_scene", "returned None")
|
||||
|
||||
print()
|
||||
|
||||
|
|
@ -70,19 +100,22 @@ print("Test 4: Exact failing pattern")
|
|||
def failing_pattern():
|
||||
try:
|
||||
failing_test = mcrfpy.Scene("failing_test")
|
||||
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
|
||||
|
||||
# This is where it fails in the demos
|
||||
grid = mcrfpy.Grid(grid_size=(14, 10))
|
||||
|
||||
# This is where it used to fail in the demos
|
||||
walls = []
|
||||
print(" About to enter range loop...")
|
||||
for x in range(1, 8):
|
||||
walls.append((x, 1))
|
||||
print(f" ✓ Created walls: {walls}")
|
||||
|
||||
expected = [(x, 1) for x in range(1, 8)]
|
||||
if walls != expected:
|
||||
fail("demo wall pattern", f"expected {expected}, got {walls}")
|
||||
return
|
||||
print(f" ok Created walls: {walls}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Error at line: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
fail("demo wall pattern", f"{type(e).__name__}: {e}")
|
||||
|
||||
failing_pattern()
|
||||
|
||||
|
|
@ -95,34 +128,53 @@ def test_append():
|
|||
walls = []
|
||||
# Test 1: Simple append
|
||||
walls.append((1, 1))
|
||||
print(" ✓ Single append works")
|
||||
|
||||
if walls != [(1, 1)]:
|
||||
fail("single append", f"got {walls}")
|
||||
return
|
||||
print(" ok Single append works")
|
||||
|
||||
# Test 2: Manual loop
|
||||
i = 0
|
||||
while i < 3:
|
||||
walls.append((i, 1))
|
||||
i += 1
|
||||
print(f" ✓ While loop append works: {walls}")
|
||||
|
||||
if walls != [(1, 1), (0, 1), (1, 1), (2, 1)]:
|
||||
fail("while loop append", f"got {walls}")
|
||||
return
|
||||
print(f" ok While loop append works: {walls}")
|
||||
|
||||
# Test 3: Range with different operations
|
||||
walls2 = []
|
||||
for x in range(3):
|
||||
tup = (x, 2)
|
||||
walls2.append(tup)
|
||||
print(f" ✓ Range with temp variable works: {walls2}")
|
||||
|
||||
if walls2 != [(0, 2), (1, 2), (2, 2)]:
|
||||
fail("range with temp variable", f"got {walls2}")
|
||||
return
|
||||
print(f" ok Range with temp variable works: {walls2}")
|
||||
|
||||
# Test 4: Direct tuple creation in append
|
||||
walls3 = []
|
||||
for x in range(3):
|
||||
walls3.append((x, 3))
|
||||
print(f" ✓ Direct tuple append works: {walls3}")
|
||||
|
||||
if walls3 != [(0, 3), (1, 3), (2, 3)]:
|
||||
fail("direct tuple append", f"got {walls3}")
|
||||
return
|
||||
print(f" ok Direct tuple append works: {walls3}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
fail("append in loop", f"{type(e).__name__}: {e}")
|
||||
|
||||
test_append()
|
||||
|
||||
print()
|
||||
print("All tests complete.")
|
||||
if failures:
|
||||
print(f"FAIL ({len(failures)} check(s) failed)")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
|
||||
print("All tests complete.")
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,31 +1,76 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Simple test for Color setter fix"""
|
||||
"""Simple test for Color setter fix
|
||||
|
||||
Original intent: assigning a cell color from a plain (r, g, b) tuple must work
|
||||
just as well as assigning a mcrfpy.Color object.
|
||||
|
||||
API update: per-cell color no longer lives on GridPoint (grid.at(x, y).color is
|
||||
gone). The successor API is a ColorLayer attached to the grid:
|
||||
cl = mcrfpy.ColorLayer(name="bg"); grid.add_layer(cl)
|
||||
cl.set((x, y), color)
|
||||
So the same tuple-vs-Color coercion is exercised through ColorLayer.set.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
print("Testing Color fix...")
|
||||
|
||||
# Test 1: Create grid
|
||||
failures = 0
|
||||
|
||||
# Test 1: Create grid + color layer
|
||||
try:
|
||||
test = mcrfpy.Scene("test")
|
||||
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
|
||||
print("✓ Grid created")
|
||||
grid = mcrfpy.Grid(grid_size=(5, 5))
|
||||
color_layer = mcrfpy.ColorLayer(name="bg")
|
||||
grid.add_layer(color_layer)
|
||||
print("+ Grid created")
|
||||
except Exception as e:
|
||||
print(f"✗ Grid creation failed: {e}")
|
||||
exit(1)
|
||||
print(f"x Grid creation failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 2: Set color with tuple
|
||||
try:
|
||||
grid.at(0, 0).color = (100, 100, 100)
|
||||
print("✓ Tuple color assignment works")
|
||||
color_layer.set((0, 0), (100, 100, 100))
|
||||
c = color_layer.at((0, 0))
|
||||
assert (c.r, c.g, c.b) == (100, 100, 100), f"expected (100,100,100), got {(c.r, c.g, c.b)}"
|
||||
print("+ Tuple color assignment works")
|
||||
except Exception as e:
|
||||
print(f"✗ Tuple assignment failed: {e}")
|
||||
print(f"x Tuple assignment failed: {e}")
|
||||
failures += 1
|
||||
|
||||
# Test 3: Set color with Color object
|
||||
try:
|
||||
grid.at(0, 0).color = mcrfpy.Color(200, 200, 200)
|
||||
print("✓ Color object assignment works!")
|
||||
color_layer.set((0, 0), mcrfpy.Color(200, 200, 200))
|
||||
c = color_layer.at((0, 0))
|
||||
assert (c.r, c.g, c.b) == (200, 200, 200), f"expected (200,200,200), got {(c.r, c.g, c.b)}"
|
||||
print("+ Color object assignment works!")
|
||||
except Exception as e:
|
||||
print(f"✗ Color assignment failed: {e}")
|
||||
print(f"x Color assignment failed: {e}")
|
||||
failures += 1
|
||||
|
||||
print("Done.")
|
||||
# Test 4: RGBA tuple (4-component) also coerces
|
||||
try:
|
||||
color_layer.set((1, 1), (10, 20, 30, 40))
|
||||
c = color_layer.at((1, 1))
|
||||
assert (c.r, c.g, c.b, c.a) == (10, 20, 30, 40), f"expected (10,20,30,40), got {(c.r, c.g, c.b, c.a)}"
|
||||
print("+ RGBA tuple assignment works")
|
||||
except Exception as e:
|
||||
print(f"x RGBA tuple assignment failed: {e}")
|
||||
failures += 1
|
||||
|
||||
# Test 5: bad input must raise, not silently succeed
|
||||
try:
|
||||
color_layer.set((2, 2), "not a color")
|
||||
print("x Bad color value was accepted")
|
||||
failures += 1
|
||||
except (TypeError, ValueError):
|
||||
print("+ Invalid color rejected")
|
||||
|
||||
if failures:
|
||||
print(f"FAIL ({failures} check(s) failed)")
|
||||
sys.exit(1)
|
||||
|
||||
print("Done.")
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test if Color assignment is the trigger"""
|
||||
"""Test if Color assignment is the trigger
|
||||
|
||||
Originally a repro script for a crash/corruption suspected in per-cell Color
|
||||
assignment (grid.at(x, y).color = Color(...)) followed by range() iteration.
|
||||
|
||||
API update: GridPoint no longer carries a .color -- per-cell color now lives on a
|
||||
ColorLayer (grid.add_layer(mcrfpy.ColorLayer(...)); layer.set((x, y), Color)).
|
||||
The original intent (bulk color writes must not corrupt the interpreter, and the
|
||||
colors must actually stick) is preserved against the new API.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
failures = []
|
||||
|
||||
print("Testing Color operations with range()...")
|
||||
print("=" * 50)
|
||||
|
|
@ -10,44 +22,61 @@ print("=" * 50)
|
|||
print("Test 1: Color assignment in grid")
|
||||
try:
|
||||
test1 = mcrfpy.Scene("test1")
|
||||
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(25, 15))
|
||||
colors = mcrfpy.ColorLayer(name="cell_color")
|
||||
grid.add_layer(colors)
|
||||
|
||||
# Assign color to a cell
|
||||
grid.at(0, 0).color = mcrfpy.Color(200, 200, 220)
|
||||
colors.set((0, 0), mcrfpy.Color(200, 200, 220))
|
||||
c = colors.at((0, 0))
|
||||
assert (c.r, c.g, c.b) == (200, 200, 220), f"color readback wrong: {c}"
|
||||
print(" ✓ Single color assignment works")
|
||||
|
||||
|
||||
# Test range
|
||||
for i in range(25):
|
||||
pass
|
||||
print(" ✓ range(25) works after single color assignment")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||
failures.append(f"Test 1: {type(e).__name__}: {e}")
|
||||
|
||||
# Test 2: Multiple color assignments
|
||||
print("\nTest 2: Multiple color assignments")
|
||||
try:
|
||||
test2 = mcrfpy.Scene("test2")
|
||||
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(25, 15))
|
||||
colors = mcrfpy.ColorLayer(name="cell_color")
|
||||
grid.add_layer(colors)
|
||||
|
||||
# Multiple properties including color
|
||||
for y in range(15):
|
||||
for x in range(25):
|
||||
grid.at(x, y).walkable = True
|
||||
grid.at(x, y).transparent = True
|
||||
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
|
||||
|
||||
colors.set((x, y), mcrfpy.Color(200, 200, 220))
|
||||
|
||||
print(" ✓ Completed all property assignments")
|
||||
|
||||
|
||||
# Every cell must have kept its value
|
||||
for y in range(15):
|
||||
for x in range(25):
|
||||
c = colors.at((x, y))
|
||||
assert (c.r, c.g, c.b) == (200, 200, 220), f"cell ({x},{y}) = {c}"
|
||||
assert grid.at(x, y).walkable is True, f"cell ({x},{y}) not walkable"
|
||||
assert grid.at(x, y).transparent is True, f"cell ({x},{y}) not transparent"
|
||||
print(" ✓ All 375 cells read back correctly")
|
||||
|
||||
# This is where it would fail
|
||||
for i in range(25):
|
||||
pass
|
||||
print(" ✓ range(25) still works!")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
failures.append(f"Test 2: {type(e).__name__}: {e}")
|
||||
|
||||
# Test 3: Exact reproduction of failing pattern
|
||||
print("\nTest 3: Exact pattern from dijkstra_demo_final.py")
|
||||
|
|
@ -55,37 +84,46 @@ try:
|
|||
# Recreate the exact function
|
||||
def create_demo():
|
||||
dijkstra_demo = mcrfpy.Scene("dijkstra_demo")
|
||||
|
||||
|
||||
# Create grid
|
||||
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
||||
grid = mcrfpy.Grid(grid_size=(25, 15))
|
||||
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||
|
||||
colors = mcrfpy.ColorLayer(name="cell_color")
|
||||
grid.add_layer(colors)
|
||||
|
||||
# Initialize all as floor
|
||||
for y in range(15):
|
||||
for x in range(25):
|
||||
grid.at(x, y).walkable = True
|
||||
grid.at(x, y).transparent = True
|
||||
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
|
||||
|
||||
colors.set((x, y), mcrfpy.Color(200, 200, 220))
|
||||
|
||||
# Create an interesting dungeon layout
|
||||
walls = []
|
||||
|
||||
|
||||
# Room walls
|
||||
# Top-left room
|
||||
for x in range(1, 8): walls.append((x, 1))
|
||||
|
||||
|
||||
return grid, walls
|
||||
|
||||
|
||||
grid, walls = create_demo()
|
||||
assert walls == [(x, 1) for x in range(1, 8)], f"walls corrupted: {walls}"
|
||||
fc = grid.fill_color
|
||||
assert (fc.r, fc.g, fc.b) == (0, 0, 0), f"fill_color corrupted: {fc}"
|
||||
print(f" ✓ Function completed successfully, walls: {walls}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
failures.append(f"Test 3: {type(e).__name__}: {e}")
|
||||
|
||||
print("\nConclusion: The bug is inconsistent and may be related to:")
|
||||
print("- Memory layout at the time of execution")
|
||||
print("- Specific bytecode patterns in the Python code")
|
||||
print("- C++ reference counting issues with Color objects")
|
||||
print("- Stack/heap corruption in the grid.at() implementation")
|
||||
print()
|
||||
if failures:
|
||||
for f in failures:
|
||||
print(f"FAILED: {f}")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,70 +1,138 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test that confirms the Color setter bug"""
|
||||
"""Test that the grid cell Color setter does not leave a pending exception.
|
||||
|
||||
Original bug: PyObject_to_sfColor in UIGridPoint.cpp called PyArg_ParseTuple to
|
||||
parse the assigned color. When handed an mcrfpy.Color object (rather than a
|
||||
tuple) the parse failed, the setter swallowed the failure without clearing the
|
||||
error indicator, and the *pending* exception then erupted out of the next,
|
||||
totally unrelated Python operation (e.g. `range(25)`).
|
||||
|
||||
GridPoint.color no longer exists; per-cell color now lives on a ColorLayer
|
||||
(grid.add_layer(mcrfpy.ColorLayer(...)); layer.set((x, y), color)). This test is
|
||||
retargeted onto that successor API, preserving the original intent:
|
||||
- a tuple is accepted
|
||||
- an mcrfpy.Color object is accepted (the case that used to fail)
|
||||
- neither leaves a stray pending exception that surfaces later
|
||||
- the assigned color actually round-trips
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
print("Testing GridPoint color setter bug...")
|
||||
failures = []
|
||||
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
if condition:
|
||||
print(f" PASS: {label}")
|
||||
else:
|
||||
print(f" FAIL: {label} {detail}")
|
||||
failures.append(label)
|
||||
|
||||
|
||||
def make_color_layer(w, h):
|
||||
scene = mcrfpy.Scene(f"colorsetter_{w}x{h}")
|
||||
grid = mcrfpy.Grid(grid_size=(w, h))
|
||||
layer = mcrfpy.ColorLayer(name="bg")
|
||||
grid.add_layer(layer)
|
||||
return grid, layer
|
||||
|
||||
|
||||
print("Testing grid cell color setter (pending-exception bug)...")
|
||||
print("=" * 50)
|
||||
|
||||
# Test 1: Setting color with tuple (old way)
|
||||
# Test 1: Setting color with a tuple (the path that always worked)
|
||||
print("Test 1: Setting color with tuple")
|
||||
try:
|
||||
test1 = mcrfpy.Scene("test1")
|
||||
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
|
||||
|
||||
# This should work (PyArg_ParseTuple expects tuple)
|
||||
grid.at(0, 0).color = (200, 200, 220)
|
||||
|
||||
# Check if exception is pending
|
||||
grid, layer = make_color_layer(5, 5)
|
||||
layer.set((0, 0), (200, 200, 220))
|
||||
|
||||
# The original bug surfaced here: an unrelated op raising the pending error.
|
||||
_ = list(range(1))
|
||||
print(" ✓ Tuple assignment works")
|
||||
|
||||
c = layer.at((0, 0))
|
||||
check("tuple assignment raises nothing", True)
|
||||
check(
|
||||
"tuple color round-trips",
|
||||
(c.r, c.g, c.b) == (200, 200, 220),
|
||||
f"got ({c.r}, {c.g}, {c.b})",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" ✗ Tuple assignment failed: {type(e).__name__}: {e}")
|
||||
check("tuple assignment", False, f"{type(e).__name__}: {e}")
|
||||
|
||||
print()
|
||||
|
||||
# Test 2: Setting color with Color object (the bug)
|
||||
# Test 2: Setting color with a Color object (this is what used to set the
|
||||
# pending exception). It must now work outright -- no exception, pending or not.
|
||||
print("Test 2: Setting color with Color object")
|
||||
try:
|
||||
test2 = mcrfpy.Scene("test2")
|
||||
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
|
||||
|
||||
# This will fail in PyArg_ParseTuple but not report it
|
||||
grid.at(0, 0).color = mcrfpy.Color(200, 200, 220)
|
||||
print(" ⚠️ Color assignment appeared to work...")
|
||||
|
||||
# But exception is pending!
|
||||
grid, layer = make_color_layer(5, 5)
|
||||
layer.set((0, 0), mcrfpy.Color(200, 200, 220))
|
||||
|
||||
# If a stale error indicator were left behind, this innocent call would
|
||||
# raise it instead.
|
||||
_ = list(range(1))
|
||||
print(" ✓ No exception detected (unexpected!)")
|
||||
|
||||
c = layer.at((0, 0))
|
||||
check("Color object assignment raises nothing", True)
|
||||
check(
|
||||
"Color object round-trips",
|
||||
(c.r, c.g, c.b) == (200, 200, 220),
|
||||
f"got ({c.r}, {c.g}, {c.b})",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" ✗ Exception detected: {type(e).__name__}: {e}")
|
||||
print(" This confirms the bug - exception was set but not raised")
|
||||
check("Color object assignment", False, f"{type(e).__name__}: {e}")
|
||||
|
||||
print()
|
||||
|
||||
# Test 3: Multiple color assignments
|
||||
# Test 3: Many Color assignments in a row (reproduces the original failure
|
||||
# shape: the error only became visible on a later, unrelated operation).
|
||||
print("Test 3: Multiple Color assignments (reproducing original bug)")
|
||||
try:
|
||||
test3 = mcrfpy.Scene("test3")
|
||||
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
||||
|
||||
# Do multiple color assignments
|
||||
for y in range(2): # Just 2 rows to be quick
|
||||
grid, layer = make_color_layer(25, 15)
|
||||
|
||||
for y in range(2):
|
||||
for x in range(25):
|
||||
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
|
||||
|
||||
print(" All color assignments completed...")
|
||||
|
||||
# This should fail
|
||||
layer.set((x, y), mcrfpy.Color(200, 200, 220))
|
||||
|
||||
# The canary from the original bug report.
|
||||
for i in range(25):
|
||||
pass
|
||||
print(" ✓ range(25) worked (unexpected!)")
|
||||
|
||||
c = layer.at((24, 1))
|
||||
check("50 Color assignments leave no pending exception", True)
|
||||
check(
|
||||
"last assigned cell round-trips",
|
||||
(c.r, c.g, c.b) == (200, 200, 220),
|
||||
f"got ({c.r}, {c.g}, {c.b})",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" ✗ range(25) failed as expected: {type(e).__name__}")
|
||||
print(" The exception was set during color assignment")
|
||||
check("multiple Color assignments", False, f"{type(e).__name__}: {e}")
|
||||
|
||||
print()
|
||||
print("Bug confirmed: PyObject_to_sfColor in UIGridPoint.cpp")
|
||||
print("doesn't clear the exception when PyArg_ParseTuple fails.")
|
||||
print("The fix: Either check PyErr_Occurred() after ParseTuple,")
|
||||
print("or support mcrfpy.Color objects directly.")
|
||||
|
||||
# Test 4: A genuinely bad color must raise a REAL exception, immediately -- the
|
||||
# other half of the bug was errors being set but never raised.
|
||||
print("Test 4: Invalid color raises immediately")
|
||||
try:
|
||||
grid, layer = make_color_layer(5, 5)
|
||||
try:
|
||||
layer.set((0, 0), "not a color")
|
||||
check("invalid color raises", False, "no exception raised")
|
||||
except TypeError:
|
||||
check("invalid color raises TypeError at the call site", True)
|
||||
|
||||
# And the failed call must not have left an error indicator behind.
|
||||
_ = list(range(1))
|
||||
check("failed set leaves no pending exception", True)
|
||||
except Exception as e:
|
||||
check("invalid color handling", False, f"{type(e).__name__}: {e}")
|
||||
|
||||
print()
|
||||
print("=" * 50)
|
||||
if failures:
|
||||
print(f"FAILED ({len(failures)}): {', '.join(failures)}")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -8,12 +8,23 @@ import sys
|
|||
import mcrfpy
|
||||
|
||||
|
||||
def make_color_layer(size=(10, 10)):
|
||||
"""Create a Grid with an attached ColorLayer, returning (grid, layer).
|
||||
|
||||
add_layer() takes a layer object and no keyword arguments; the ColorLayer
|
||||
constructor is what accepts name/z_index.
|
||||
"""
|
||||
grid = mcrfpy.Grid(grid_size=size)
|
||||
layer = mcrfpy.ColorLayer(name='color', z_index=0)
|
||||
grid.add_layer(layer)
|
||||
return grid, layer
|
||||
|
||||
|
||||
def test_apply_threshold_basic():
|
||||
"""apply_threshold sets colors in range"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
layer = grid.add_layer('color', z_index=0)
|
||||
grid, layer = make_color_layer()
|
||||
layer.fill((0, 0, 0, 0)) # Clear all
|
||||
|
||||
# Apply threshold - all cells should get blue
|
||||
|
|
@ -32,8 +43,7 @@ def test_apply_threshold_with_alpha():
|
|||
"""apply_threshold handles RGBA colors"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
layer = grid.add_layer('color', z_index=0)
|
||||
grid, layer = make_color_layer()
|
||||
|
||||
layer.apply_threshold(hmap, (0.0, 1.0), (100, 150, 200, 128))
|
||||
|
||||
|
|
@ -47,8 +57,7 @@ def test_apply_threshold_preserves_outside():
|
|||
"""apply_threshold doesn't modify cells outside range"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
layer = grid.add_layer('color', z_index=0)
|
||||
grid, layer = make_color_layer()
|
||||
layer.fill((255, 0, 0)) # Fill with red
|
||||
|
||||
# Apply threshold for range that doesn't include 0.5
|
||||
|
|
@ -65,8 +74,7 @@ def test_apply_threshold_with_color_object():
|
|||
"""apply_threshold accepts Color objects"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
layer = grid.add_layer('color', z_index=0)
|
||||
grid, layer = make_color_layer()
|
||||
|
||||
color = mcrfpy.Color(50, 100, 150)
|
||||
layer.apply_threshold(hmap, (0.0, 1.0), color)
|
||||
|
|
@ -80,8 +88,7 @@ def test_apply_threshold_size_mismatch():
|
|||
"""apply_threshold rejects mismatched HeightMap size"""
|
||||
hmap = mcrfpy.HeightMap((5, 5)) # Different size
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
layer = grid.add_layer('color', z_index=0)
|
||||
grid, layer = make_color_layer()
|
||||
|
||||
try:
|
||||
layer.apply_threshold(hmap, (0.0, 1.0), (255, 0, 0))
|
||||
|
|
@ -97,8 +104,7 @@ def test_apply_gradient_basic():
|
|||
"""apply_gradient interpolates colors"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
layer = grid.add_layer('color', z_index=0)
|
||||
grid, layer = make_color_layer()
|
||||
|
||||
# Apply gradient from black to white
|
||||
result = layer.apply_gradient(hmap, (0.0, 1.0), (0, 0, 0), (255, 255, 255))
|
||||
|
|
@ -118,8 +124,7 @@ def test_apply_gradient_full_range():
|
|||
# Test at minimum of range
|
||||
hmap_low = mcrfpy.HeightMap((10, 10), fill=0.0)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
layer = grid.add_layer('color', z_index=0)
|
||||
grid, layer = make_color_layer()
|
||||
|
||||
layer.apply_gradient(hmap_low, (0.0, 1.0), (100, 0, 0), (200, 255, 0))
|
||||
|
||||
|
|
@ -144,8 +149,7 @@ def test_apply_gradient_preserves_outside():
|
|||
"""apply_gradient doesn't modify cells outside range"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
layer = grid.add_layer('color', z_index=0)
|
||||
grid, layer = make_color_layer()
|
||||
layer.fill((255, 0, 0)) # Fill with red
|
||||
|
||||
# Apply gradient for range that doesn't include 0.5
|
||||
|
|
@ -161,8 +165,7 @@ def test_apply_ranges_fixed_colors():
|
|||
"""apply_ranges with fixed colors"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
layer = grid.add_layer('color', z_index=0)
|
||||
grid, layer = make_color_layer()
|
||||
layer.fill((0, 0, 0))
|
||||
|
||||
result = layer.apply_ranges(hmap, [
|
||||
|
|
@ -183,8 +186,7 @@ def test_apply_ranges_gradient():
|
|||
"""apply_ranges with gradient specification"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
layer = grid.add_layer('color', z_index=0)
|
||||
grid, layer = make_color_layer()
|
||||
|
||||
# Gradient from (0,0,0) to (255,255,255) over range [0,1]
|
||||
# At value 0.5, should be ~(127,127,127)
|
||||
|
|
@ -201,8 +203,7 @@ def test_apply_ranges_mixed():
|
|||
"""apply_ranges with mixed fixed and gradient entries"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
layer = grid.add_layer('color', z_index=0)
|
||||
grid, layer = make_color_layer()
|
||||
layer.fill((0, 0, 0))
|
||||
|
||||
# Test mixed: gradient that includes 0.5
|
||||
|
|
@ -222,8 +223,7 @@ def test_apply_ranges_later_wins():
|
|||
"""apply_ranges: later ranges override earlier ones"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
layer = grid.add_layer('color', z_index=0)
|
||||
grid, layer = make_color_layer()
|
||||
|
||||
layer.apply_ranges(hmap, [
|
||||
((0.0, 1.0), (255, 0, 0)), # Red, matches everything
|
||||
|
|
@ -240,8 +240,7 @@ def test_apply_ranges_no_match_unchanged():
|
|||
"""apply_ranges leaves unmatched cells unchanged"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
layer = grid.add_layer('color', z_index=0)
|
||||
grid, layer = make_color_layer()
|
||||
layer.fill((128, 128, 128)) # Gray marker
|
||||
|
||||
layer.apply_ranges(hmap, [
|
||||
|
|
@ -259,8 +258,7 @@ def test_apply_threshold_invalid_range():
|
|||
"""apply_threshold rejects min > max"""
|
||||
hmap = mcrfpy.HeightMap((10, 10))
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
layer = grid.add_layer('color', z_index=0)
|
||||
grid, layer = make_color_layer()
|
||||
|
||||
try:
|
||||
layer.apply_threshold(hmap, (1.0, 0.0), (255, 0, 0))
|
||||
|
|
@ -277,8 +275,7 @@ def test_apply_gradient_narrow_range():
|
|||
# Use a value exactly at the range
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
layer = grid.add_layer('color', z_index=0)
|
||||
grid, layer = make_color_layer()
|
||||
|
||||
# Apply gradient over exact value (min == max)
|
||||
layer.apply_gradient(hmap, (0.5, 0.5), (0, 0, 0), (255, 255, 255))
|
||||
|
|
|
|||
|
|
@ -8,32 +8,49 @@ Demonstrates:
|
|||
2. Getting distances to any position
|
||||
3. Finding paths from any position back to the root
|
||||
4. Multi-target pathfinding (flee/approach scenarios)
|
||||
|
||||
API notes (updated for current mcrfpy):
|
||||
- The `mcrfpy.libtcod` module is gone. Its successor is the GridData Dijkstra API:
|
||||
grid.grid_data.get_dijkstra_map(root=(x, y)) -> DijkstraMap
|
||||
dmap.root / dmap.distance((x, y)) / dmap.path_from((x, y))
|
||||
grid.grid_data.clear_dijkstra_maps() (invalidate after walkability changes)
|
||||
- GridPoint has no .tilesprite/.color; tiles go on a TileLayer, colors on a ColorLayer.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
from mcrfpy import libtcod
|
||||
import sys
|
||||
|
||||
failures = []
|
||||
|
||||
def check(cond, message):
|
||||
"""Record a failed check instead of aborting, so all checks run."""
|
||||
if not cond:
|
||||
failures.append(message)
|
||||
print(f" FAIL: {message}")
|
||||
return cond
|
||||
|
||||
def adjacent(a, b):
|
||||
"""True if a and b are the same cell or 8-way neighbors."""
|
||||
return abs(int(a[0]) - int(b[0])) <= 1 and abs(int(a[1]) - int(b[1])) <= 1
|
||||
|
||||
def create_test_grid():
|
||||
"""Create a test grid with obstacles"""
|
||||
dijkstra_test = mcrfpy.Scene("dijkstra_test")
|
||||
|
||||
# Create grid
|
||||
grid = mcrfpy.Grid(grid_w=20, grid_h=20)
|
||||
# Create grid (already has a default tile layer)
|
||||
grid = mcrfpy.Grid(grid_size=(20, 20))
|
||||
|
||||
# Add color layer for cell coloring
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
# Store color_layer on grid for access elsewhere
|
||||
grid._color_layer = color_layer
|
||||
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||
grid.add_layer(color_layer)
|
||||
|
||||
data = grid.grid_data
|
||||
|
||||
# Initialize all cells as walkable
|
||||
for y in range(grid.grid_h):
|
||||
for x in range(grid.grid_w):
|
||||
for y in range(data.grid_h):
|
||||
for x in range(data.grid_w):
|
||||
cell = grid.at(x, y)
|
||||
cell.walkable = True
|
||||
cell.transparent = True
|
||||
cell.tilesprite = 46 # . period
|
||||
color_layer.set(x, y, mcrfpy.Color(50, 50, 50))
|
||||
color_layer.set((x, y), mcrfpy.Color(50, 50, 50))
|
||||
|
||||
# Create some walls to make pathfinding interesting
|
||||
# Vertical wall
|
||||
|
|
@ -41,187 +58,261 @@ def create_test_grid():
|
|||
cell = grid.at(10, y)
|
||||
cell.walkable = False
|
||||
cell.transparent = False
|
||||
cell.tilesprite = 219 # Block
|
||||
color_layer.set(10, y, mcrfpy.Color(100, 100, 100))
|
||||
color_layer.set((10, y), mcrfpy.Color(100, 100, 100))
|
||||
|
||||
# Horizontal wall
|
||||
for x in range(5, 15):
|
||||
if x != 10: # Leave a gap
|
||||
if x != 10: # (10, 10) is already part of the vertical wall
|
||||
cell = grid.at(x, 10)
|
||||
cell.walkable = False
|
||||
cell.transparent = False
|
||||
cell.tilesprite = 219
|
||||
color_layer.set(x, 10, mcrfpy.Color(100, 100, 100))
|
||||
color_layer.set((x, 10), mcrfpy.Color(100, 100, 100))
|
||||
|
||||
return grid
|
||||
# Walkability changed after construction; drop any cached maps
|
||||
data.clear_dijkstra_maps()
|
||||
|
||||
return grid, color_layer
|
||||
|
||||
def test_basic_dijkstra():
|
||||
"""Test basic Dijkstra functionality"""
|
||||
print("\n=== Testing Basic Dijkstra ===")
|
||||
|
||||
grid = create_test_grid()
|
||||
|
||||
|
||||
grid, _color_layer = create_test_grid()
|
||||
data = grid.grid_data
|
||||
|
||||
# Compute Dijkstra map from position (5, 5)
|
||||
root_x, root_y = 5, 5
|
||||
print(f"Computing Dijkstra map from root ({root_x}, {root_y})")
|
||||
grid.compute_dijkstra(root_x, root_y)
|
||||
|
||||
dmap = data.get_dijkstra_map(root=(root_x, root_y))
|
||||
check(dmap is not None, "get_dijkstra_map returned None")
|
||||
check((dmap.root.x, dmap.root.y) == (root_x, root_y),
|
||||
f"dmap.root {dmap.root} != root ({root_x}, {root_y})")
|
||||
|
||||
# Test getting distances to various points
|
||||
# (x, y, expected) -- expected None means "unreachable"
|
||||
test_points = [
|
||||
(5, 5), # Root position (should be 0)
|
||||
(6, 5), # Adjacent (should be 1)
|
||||
(7, 5), # Two steps away
|
||||
(15, 15), # Far corner
|
||||
(10, 10), # On a wall (should be unreachable)
|
||||
(5, 5, 0.0), # Root position
|
||||
(6, 5, 1.0), # Adjacent
|
||||
(7, 5, 2.0), # Two steps away
|
||||
(15, 15, None), # Far corner: reachable, distance checked below
|
||||
(10, 10, None), # On a wall: unreachable
|
||||
]
|
||||
|
||||
|
||||
print("\nDistances from root:")
|
||||
for x, y in test_points:
|
||||
distance = grid.get_dijkstra_distance(x, y)
|
||||
for x, y, expected in test_points:
|
||||
distance = dmap.distance((x, y))
|
||||
if distance is None:
|
||||
print(f" ({x:2}, {y:2}): UNREACHABLE")
|
||||
else:
|
||||
print(f" ({x:2}, {y:2}): {distance:.1f}")
|
||||
|
||||
|
||||
if (x, y) == (10, 10):
|
||||
check(distance is None, "wall cell (10, 10) should be unreachable")
|
||||
elif (x, y) == (15, 15):
|
||||
# Reachable by going around the walls; exact cost depends on
|
||||
# diagonal_cost, but it must be finite and clearly non-trivial.
|
||||
check(distance is not None, "(15, 15) should be reachable around the walls")
|
||||
if distance is not None:
|
||||
check(distance > 10.0, f"(15, 15) distance {distance} implausibly short")
|
||||
else:
|
||||
check(distance is not None, f"({x}, {y}) should be reachable")
|
||||
if distance is not None:
|
||||
check(abs(distance - expected) < 0.001,
|
||||
f"({x}, {y}) distance {distance} != expected {expected}")
|
||||
|
||||
# Test getting paths
|
||||
print("\nPaths to root:")
|
||||
for x, y in [(15, 5), (15, 15), (5, 15)]:
|
||||
path = grid.get_dijkstra_path(x, y)
|
||||
if path:
|
||||
print(f" From ({x}, {y}): {len(path)} steps")
|
||||
# Show first few steps
|
||||
for i, (px, py) in enumerate(path[:3]):
|
||||
print(f" Step {i+1}: ({px}, {py})")
|
||||
if len(path) > 3:
|
||||
print(f" ... {len(path)-3} more steps")
|
||||
path = dmap.path_from((x, y))
|
||||
steps = list(path) if path else []
|
||||
if steps:
|
||||
print(f" From ({x}, {y}): {len(steps)} steps")
|
||||
for i, step in enumerate(steps[:3]):
|
||||
print(f" Step {i+1}: ({step.x}, {step.y})")
|
||||
if len(steps) > 3:
|
||||
print(f" ... {len(steps)-3} more steps")
|
||||
else:
|
||||
print(f" From ({x}, {y}): No path found")
|
||||
|
||||
def test_libtcod_interface():
|
||||
"""Test the libtcod module interface"""
|
||||
print("\n=== Testing libtcod Interface ===")
|
||||
|
||||
grid = create_test_grid()
|
||||
|
||||
# Use libtcod functions
|
||||
print("Using libtcod.dijkstra_* functions:")
|
||||
|
||||
# Create dijkstra context (returns grid)
|
||||
dijkstra = libtcod.dijkstra_new(grid)
|
||||
print(f"Created Dijkstra context: {type(dijkstra)}")
|
||||
|
||||
# Compute from a position
|
||||
libtcod.dijkstra_compute(grid, 10, 2)
|
||||
print("Computed Dijkstra map from (10, 2)")
|
||||
|
||||
# Get distance using libtcod
|
||||
distance = libtcod.dijkstra_get_distance(grid, 10, 17)
|
||||
check(len(steps) > 0, f"no path from ({x}, {y}) back to root")
|
||||
if steps:
|
||||
# An AStarPath walks origin -> destination: successive steps must be
|
||||
# adjacent, the first step must neighbor the query position, and the
|
||||
# last step must be the root. (This is what find_path() returns.)
|
||||
cells = [(int(s.x), int(s.y)) for s in steps]
|
||||
check(adjacent(cells[0], (x, y)),
|
||||
f"path from ({x}, {y}): first step {cells[0]} is not adjacent to the start")
|
||||
check(cells[-1] == (root_x, root_y),
|
||||
f"path from ({x}, {y}) ends at {cells[-1]}, not the root ({root_x}, {root_y})")
|
||||
for a, b in zip(cells, cells[1:]):
|
||||
check(adjacent(a, b),
|
||||
f"path from ({x}, {y}) jumps from {a} to {b} (not adjacent)")
|
||||
for cell in cells:
|
||||
check(grid.at(cell[0], cell[1]).walkable,
|
||||
f"path from ({x}, {y}) crosses wall at {cell}")
|
||||
|
||||
def test_dijkstra_map_interface():
|
||||
"""Test the DijkstraMap object interface (successor to the old libtcod module)"""
|
||||
print("\n=== Testing DijkstraMap Interface ===")
|
||||
|
||||
grid, _color_layer = create_test_grid()
|
||||
data = grid.grid_data
|
||||
|
||||
# Create dijkstra map (successor of libtcod.dijkstra_new + dijkstra_compute)
|
||||
dijkstra = data.get_dijkstra_map(root=(10, 2))
|
||||
print(f"Created Dijkstra map: {type(dijkstra).__name__}, root={dijkstra.root}")
|
||||
check(isinstance(dijkstra, mcrfpy.DijkstraMap),
|
||||
"get_dijkstra_map did not return a DijkstraMap")
|
||||
|
||||
# Get distance (successor of libtcod.dijkstra_get_distance)
|
||||
distance = dijkstra.distance((10, 17))
|
||||
print(f"Distance to (10, 17): {distance}")
|
||||
|
||||
# Get path using libtcod
|
||||
path = libtcod.dijkstra_path_to(grid, 10, 17)
|
||||
print(f"Path from (10, 17) to root: {len(path) if path else 0} steps")
|
||||
check(distance is not None, "(10, 17) should be reachable from (10, 2)")
|
||||
|
||||
# Get path (successor of libtcod.dijkstra_path_to)
|
||||
path = dijkstra.path_from((10, 17))
|
||||
steps = [(int(s.x), int(s.y)) for s in path] if path else []
|
||||
print(f"Path from (10, 17) to root: {len(steps)} steps -> {steps[:4]}...")
|
||||
check(len(steps) > 0, "no path from (10, 17) to root (10, 2)")
|
||||
if steps:
|
||||
check((path.origin.x, path.origin.y) == (10, 17), "AStarPath.origin is not the query pos")
|
||||
check((path.destination.x, path.destination.y) == (10, 2),
|
||||
"AStarPath.destination is not the root")
|
||||
check(adjacent(steps[0], (10, 17)),
|
||||
f"first step {steps[0]} is not adjacent to the start (10, 17)")
|
||||
check(steps[-1] == (10, 2), f"path ends at {steps[-1]}, not root (10, 2)")
|
||||
|
||||
# step_from(pos) is documented as "single step from position toward root":
|
||||
# it must be the same as the first element of path_from(pos), and adjacent.
|
||||
step = dijkstra.step_from((10, 17))
|
||||
print(f"step_from((10, 17)) -> {step}")
|
||||
check(step is not None, "step_from returned None for a reachable cell")
|
||||
if step is not None:
|
||||
check(adjacent((int(step.x), int(step.y)), (10, 17)),
|
||||
f"step_from((10, 17)) returned ({int(step.x)}, {int(step.y)}), which is not adjacent")
|
||||
|
||||
# Maps are cached per-root; asking again yields an equivalent map
|
||||
again = data.get_dijkstra_map(root=(10, 2))
|
||||
check((again.root.x, again.root.y) == (10, 2), "cached map lost its root")
|
||||
check(again.distance((10, 17)) == distance, "cached map distance changed")
|
||||
|
||||
def test_multi_target_scenario():
|
||||
"""Test fleeing/approaching multiple targets"""
|
||||
print("\n=== Testing Multi-Target Scenario ===")
|
||||
|
||||
grid = create_test_grid()
|
||||
|
||||
|
||||
grid, color_layer = create_test_grid()
|
||||
data = grid.grid_data
|
||||
|
||||
# Place three "threats" and compute their Dijkstra maps
|
||||
threats = [(3, 3), (17, 3), (10, 17)]
|
||||
|
||||
|
||||
print("Computing threat distances...")
|
||||
threat_distances = []
|
||||
|
||||
|
||||
for i, (tx, ty) in enumerate(threats):
|
||||
# Mark threat position
|
||||
cell = grid.at(tx, ty)
|
||||
cell.tilesprite = 84 # T for threat
|
||||
grid._color_layer.set(tx, ty, mcrfpy.Color(255, 0, 0))
|
||||
|
||||
color_layer.set((tx, ty), mcrfpy.Color(255, 0, 0))
|
||||
|
||||
# Compute Dijkstra from this threat
|
||||
grid.compute_dijkstra(tx, ty)
|
||||
|
||||
dmap = data.get_dijkstra_map(root=(tx, ty))
|
||||
|
||||
# Store distances for all cells
|
||||
distances = {}
|
||||
for y in range(grid.grid_h):
|
||||
for x in range(grid.grid_w):
|
||||
d = grid.get_dijkstra_distance(x, y)
|
||||
for y in range(data.grid_h):
|
||||
for x in range(data.grid_w):
|
||||
d = dmap.distance((x, y))
|
||||
if d is not None:
|
||||
distances[(x, y)] = d
|
||||
|
||||
|
||||
threat_distances.append(distances)
|
||||
print(f" Threat {i+1} at ({tx}, {ty}): {len(distances)} reachable cells")
|
||||
|
||||
|
||||
check(distances.get((tx, ty)) == 0.0,
|
||||
f"threat {i+1} root ({tx}, {ty}) should have distance 0")
|
||||
check(len(distances) > 100,
|
||||
f"threat {i+1} only reached {len(distances)} cells; map looks broken")
|
||||
check((10, 10) not in distances,
|
||||
f"threat {i+1} reached wall cell (10, 10)")
|
||||
|
||||
# Find safest position (farthest from all threats)
|
||||
print("\nFinding safest position...")
|
||||
best_pos = None
|
||||
best_min_dist = 0
|
||||
|
||||
for y in range(grid.grid_h):
|
||||
for x in range(grid.grid_w):
|
||||
|
||||
for y in range(data.grid_h):
|
||||
for x in range(data.grid_w):
|
||||
# Skip if not walkable
|
||||
if not grid.at(x, y).walkable:
|
||||
continue
|
||||
|
||||
|
||||
# Get minimum distance to any threat
|
||||
min_dist = float('inf')
|
||||
for threat_dist in threat_distances:
|
||||
if (x, y) in threat_dist:
|
||||
min_dist = min(min_dist, threat_dist[(x, y)])
|
||||
|
||||
|
||||
# Track best position
|
||||
if min_dist > best_min_dist and min_dist != float('inf'):
|
||||
best_min_dist = min_dist
|
||||
best_pos = (x, y)
|
||||
|
||||
|
||||
check(best_pos is not None, "no safest position found (all cells unreachable?)")
|
||||
if best_pos:
|
||||
print(f"Safest position: {best_pos} (min distance to threats: {best_min_dist:.1f})")
|
||||
check(grid.at(best_pos[0], best_pos[1]).walkable, "safest position is a wall")
|
||||
check(best_min_dist > 0, "safest position sits on top of a threat")
|
||||
# Mark safe position
|
||||
cell = grid.at(best_pos[0], best_pos[1])
|
||||
cell.tilesprite = 83 # S for safe
|
||||
grid._color_layer.set(best_pos[0], best_pos[1], mcrfpy.Color(0, 255, 0))
|
||||
color_layer.set((best_pos[0], best_pos[1]), mcrfpy.Color(0, 255, 0))
|
||||
|
||||
# Multi-source Dijkstra: one map rooted at ALL threats at once. Its distance
|
||||
# to any cell must equal the min over the individual per-threat maps.
|
||||
multi = data.get_dijkstra_map(roots=threats)
|
||||
mismatches = 0
|
||||
for (x, y), _ in list(threat_distances[0].items())[:200]:
|
||||
expected = min(td[(x, y)] for td in threat_distances if (x, y) in td)
|
||||
got = multi.distance((x, y))
|
||||
if got is None or abs(got - expected) > 0.001:
|
||||
mismatches += 1
|
||||
check(mismatches == 0,
|
||||
f"multi-source Dijkstra disagrees with min-of-single-source on {mismatches} cells")
|
||||
|
||||
def main():
|
||||
print("McRogueFace Dijkstra Pathfinding Test")
|
||||
print("=====================================")
|
||||
|
||||
# Set up scene so the grid is actually attached to a live UI tree
|
||||
dijkstra_test = mcrfpy.Scene("dijkstra_test")
|
||||
grid, _color_layer = create_test_grid()
|
||||
grid.pos = (0, 0)
|
||||
grid.size = (400, 400)
|
||||
ui = dijkstra_test.children
|
||||
ui.append(grid)
|
||||
|
||||
title = mcrfpy.Caption(text="Dijkstra Pathfinding Test", pos=(10, 10))
|
||||
title.fill_color = mcrfpy.Color(255, 255, 255)
|
||||
ui.append(title)
|
||||
|
||||
dijkstra_test.activate()
|
||||
|
||||
def run_test(timer, runtime):
|
||||
"""Timer callback to run tests after scene loads"""
|
||||
test_basic_dijkstra()
|
||||
test_libtcod_interface()
|
||||
test_dijkstra_map_interface()
|
||||
test_multi_target_scenario()
|
||||
|
||||
|
||||
print("\n=== Dijkstra Implementation Test Complete ===")
|
||||
print("✓ Basic Dijkstra computation works")
|
||||
print("✓ Distance queries work")
|
||||
print("✓ Path finding works")
|
||||
print("✓ libtcod interface works")
|
||||
print("✓ Multi-target scenarios work")
|
||||
|
||||
# Take screenshot
|
||||
try:
|
||||
from mcrfpy import automation
|
||||
automation.screenshot("dijkstra_test.png")
|
||||
print("\nScreenshot saved: dijkstra_test.png")
|
||||
except:
|
||||
pass
|
||||
|
||||
if failures:
|
||||
print(f"\n{len(failures)} check(s) FAILED:")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
print("FAIL")
|
||||
sys.exit(1)
|
||||
|
||||
print("Basic Dijkstra computation works")
|
||||
print("Distance queries work")
|
||||
print("Path finding works")
|
||||
print("DijkstraMap interface works")
|
||||
print("Multi-target scenarios work")
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
||||
# Main execution
|
||||
print("McRogueFace Dijkstra Pathfinding Test")
|
||||
print("=====================================")
|
||||
|
||||
# Set up scene
|
||||
grid = create_test_grid()
|
||||
ui = dijkstra_test.children
|
||||
ui.append(grid)
|
||||
|
||||
# Add title
|
||||
title = mcrfpy.Caption(pos=(10, 10), text="Dijkstra Pathfinding Test")
|
||||
title.fill_color = mcrfpy.Color(255, 255, 255)
|
||||
ui.append(title)
|
||||
|
||||
# Set timer to run tests
|
||||
test_timer = mcrfpy.Timer("test", run_test, 100, once=True)
|
||||
|
||||
# Show scene
|
||||
dijkstra_test.activate()
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -1,133 +1,164 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test that method documentation is properly accessible in Python."""
|
||||
"""Test that method documentation is properly accessible in Python.
|
||||
|
||||
Rot repair: this test used to only *print* what it found (so a missing
|
||||
docstring silently "passed"), and it probed the pre-#350 API surface
|
||||
(setScene/createScene/sceneUI/currentScene, the module-level audio
|
||||
functions). It now asserts against the current mcrfpy API and fails loudly
|
||||
on any missing docstring.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
import types
|
||||
import io
|
||||
import contextlib
|
||||
|
||||
failures = []
|
||||
|
||||
def check(condition, message):
|
||||
if condition:
|
||||
print(f" PASS: {message}")
|
||||
else:
|
||||
print(f" FAIL: {message}")
|
||||
failures.append(message)
|
||||
|
||||
def test_module_doc():
|
||||
"""Test module-level documentation."""
|
||||
print("=== Module Documentation ===")
|
||||
print(f"Module: {mcrfpy.__name__}")
|
||||
print(f"Doc: {mcrfpy.__doc__[:100]}..." if mcrfpy.__doc__ else "No module doc")
|
||||
doc = mcrfpy.__doc__
|
||||
check(bool(doc and doc.strip()), "mcrfpy has a module docstring")
|
||||
check(bool(doc and 'McRogueFace' in doc), "module docstring names McRogueFace")
|
||||
print()
|
||||
|
||||
|
||||
def test_method_docs():
|
||||
"""Test method documentation."""
|
||||
"""Every public module-level function must be documented."""
|
||||
print("=== Method Documentation ===")
|
||||
|
||||
# Test main API methods
|
||||
|
||||
# Current module-level API (audio moved to Sound/Music/SoundBuffer classes;
|
||||
# setScene/createScene/currentScene/sceneUI replaced by mcrfpy.Scene).
|
||||
methods = [
|
||||
'createSoundBuffer', 'loadMusic', 'setMusicVolume', 'setSoundVolume',
|
||||
'playSound', 'getMusicVolume', 'getSoundVolume', 'sceneUI',
|
||||
'currentScene', 'setScene', 'createScene', 'keypressScene',
|
||||
'exit', 'setScale', 'find', 'findAll',
|
||||
'getMetrics'
|
||||
'bresenham', 'end_benchmark', 'exit', 'find', 'find_all', 'get_metrics',
|
||||
'lock', 'log_benchmark', 'set_dev_console', 'set_scale',
|
||||
'start_benchmark', 'step',
|
||||
]
|
||||
|
||||
|
||||
for method_name in methods:
|
||||
if hasattr(mcrfpy, method_name):
|
||||
method = getattr(mcrfpy, method_name)
|
||||
doc = method.__doc__
|
||||
if doc:
|
||||
# Extract first line of docstring
|
||||
first_line = doc.strip().split('\n')[0]
|
||||
print(f"{method_name}: {first_line}")
|
||||
else:
|
||||
print(f"{method_name}: NO DOCUMENTATION")
|
||||
check(hasattr(mcrfpy, method_name), f"mcrfpy.{method_name} exists")
|
||||
method = getattr(mcrfpy, method_name, None)
|
||||
doc = getattr(method, '__doc__', None)
|
||||
check(bool(doc and doc.strip()), f"mcrfpy.{method_name} has documentation")
|
||||
|
||||
# Nothing public may be undocumented, even if not listed above.
|
||||
for name in dir(mcrfpy):
|
||||
if name.startswith('_'):
|
||||
continue
|
||||
obj = getattr(mcrfpy, name)
|
||||
if isinstance(obj, types.BuiltinFunctionType):
|
||||
check(bool(obj.__doc__), f"module function {name} has documentation")
|
||||
print()
|
||||
|
||||
def test_class_docs():
|
||||
"""Test class documentation."""
|
||||
print("=== Class Documentation ===")
|
||||
|
||||
classes = ['Frame', 'Caption', 'Sprite', 'Grid', 'Entity', 'Color', 'Vector', 'Texture', 'Font', 'Timer']
|
||||
|
||||
|
||||
classes = ['Frame', 'Caption', 'Sprite', 'Grid', 'GridView', 'GridData',
|
||||
'Entity', 'Color', 'Vector', 'Texture', 'Font', 'Timer', 'Scene']
|
||||
|
||||
for class_name in classes:
|
||||
if hasattr(mcrfpy, class_name):
|
||||
cls = getattr(mcrfpy, class_name)
|
||||
doc = cls.__doc__
|
||||
if doc:
|
||||
# Extract first line
|
||||
first_line = doc.strip().split('\n')[0]
|
||||
print(f"{class_name}: {first_line[:80]}...")
|
||||
else:
|
||||
print(f"{class_name}: NO DOCUMENTATION")
|
||||
check(hasattr(mcrfpy, class_name), f"mcrfpy.{class_name} exists")
|
||||
cls = getattr(mcrfpy, class_name, None)
|
||||
doc = getattr(cls, '__doc__', None)
|
||||
check(bool(doc and doc.strip()), f"class {class_name} has documentation")
|
||||
if doc:
|
||||
print(f" {class_name}: {doc.strip().splitlines()[0][:70]}")
|
||||
print()
|
||||
|
||||
def test_property_docs():
|
||||
"""Test property documentation."""
|
||||
"""Every getset property on the core drawables must be documented."""
|
||||
print("=== Property Documentation ===")
|
||||
|
||||
# Test Frame properties
|
||||
if hasattr(mcrfpy, 'Frame'):
|
||||
frame_props = ['x', 'y', 'w', 'h', 'fill_color', 'outline_color', 'outline', 'children', 'visible', 'z_index']
|
||||
print("Frame properties:")
|
||||
for prop_name in frame_props:
|
||||
prop = getattr(mcrfpy.Frame, prop_name, None)
|
||||
if prop and hasattr(prop, '__doc__'):
|
||||
print(f" {prop_name}: {prop.__doc__}")
|
||||
|
||||
frame_props = ['x', 'y', 'w', 'h', 'fill_color', 'outline_color', 'outline',
|
||||
'children', 'visible', 'z_index']
|
||||
for prop_name in frame_props:
|
||||
prop = getattr(mcrfpy.Frame, prop_name, None)
|
||||
check(prop is not None, f"Frame.{prop_name} exists")
|
||||
check(bool(prop is not None and prop.__doc__),
|
||||
f"Frame.{prop_name} has documentation")
|
||||
|
||||
# No getset descriptor on the core types may be undocumented.
|
||||
for class_name in ['Frame', 'Caption', 'Sprite', 'Grid', 'Entity',
|
||||
'Color', 'Vector', 'Timer']:
|
||||
cls = getattr(mcrfpy, class_name)
|
||||
for prop_name, prop in vars(cls).items():
|
||||
if isinstance(prop, types.GetSetDescriptorType):
|
||||
check(bool(prop.__doc__),
|
||||
f"{class_name}.{prop_name} has documentation")
|
||||
print()
|
||||
|
||||
def test_method_signatures():
|
||||
"""Test that methods have correct signatures in docs."""
|
||||
"""Test that docstrings carry a usable signature line."""
|
||||
print("=== Method Signatures ===")
|
||||
|
||||
# Check a few key methods
|
||||
if hasattr(mcrfpy, 'setScene'):
|
||||
doc = mcrfpy.setScene.__doc__
|
||||
if doc and 'setScene(scene: str, transition: str = None, duration: float = 0.0)' in doc:
|
||||
print("✓ setScene signature correct")
|
||||
else:
|
||||
print("✗ setScene signature incorrect or missing")
|
||||
|
||||
if hasattr(mcrfpy, 'Timer'):
|
||||
doc = mcrfpy.Timer.__doc__
|
||||
if doc and 'Timer' in doc:
|
||||
print("+ Timer class documentation present")
|
||||
else:
|
||||
print("x Timer class documentation missing")
|
||||
|
||||
if hasattr(mcrfpy, 'find'):
|
||||
doc = mcrfpy.find.__doc__
|
||||
if doc and 'find(name: str, scene: str = None)' in doc:
|
||||
print("✓ find signature correct")
|
||||
else:
|
||||
print("✗ find signature incorrect or missing")
|
||||
|
||||
doc = mcrfpy.find.__doc__
|
||||
check(bool(doc and 'find(name: str, scene: str = None)' in doc),
|
||||
"find() signature present in docstring")
|
||||
|
||||
doc = mcrfpy.step.__doc__
|
||||
check(bool(doc and 'step(' in doc), "step() signature present in docstring")
|
||||
|
||||
doc = mcrfpy.Timer.__doc__
|
||||
check(bool(doc and 'Timer(' in doc), "Timer signature present in class doc")
|
||||
|
||||
doc = mcrfpy.Scene.__doc__
|
||||
check(bool(doc and 'Scene(' in doc), "Scene signature present in class doc")
|
||||
|
||||
# Bound method documentation (animation is now an object method, #229).
|
||||
doc = mcrfpy.Frame.animate.__doc__
|
||||
check(bool(doc and 'animate(' in doc),
|
||||
"Frame.animate() signature present in docstring")
|
||||
print()
|
||||
|
||||
def test_help_output():
|
||||
"""Test Python help() function output."""
|
||||
print("=== Help Function Test ===")
|
||||
print("Testing help(mcrfpy.setScene):")
|
||||
import io
|
||||
import contextlib
|
||||
|
||||
# Capture help output
|
||||
|
||||
buffer = io.StringIO()
|
||||
with contextlib.redirect_stdout(buffer):
|
||||
help(mcrfpy.setScene)
|
||||
|
||||
help(mcrfpy.find)
|
||||
help_text = buffer.getvalue()
|
||||
if 'transition to a different scene' in help_text:
|
||||
print("✓ Help text contains expected documentation")
|
||||
else:
|
||||
print("✗ Help text missing expected documentation")
|
||||
check('Find the first UI element' in help_text,
|
||||
"help(mcrfpy.find) contains its documentation")
|
||||
|
||||
buffer = io.StringIO()
|
||||
with contextlib.redirect_stdout(buffer):
|
||||
help(mcrfpy.Frame)
|
||||
help_text = buffer.getvalue()
|
||||
check('Frame' in help_text and 'animate' in help_text,
|
||||
"help(mcrfpy.Frame) lists the class and its methods")
|
||||
print()
|
||||
|
||||
def main():
|
||||
"""Run all documentation tests."""
|
||||
print("McRogueFace Documentation Tests")
|
||||
print("===============================\n")
|
||||
|
||||
|
||||
test_module_doc()
|
||||
test_method_docs()
|
||||
test_class_docs()
|
||||
test_property_docs()
|
||||
test_method_signatures()
|
||||
test_help_output()
|
||||
|
||||
|
||||
if failures:
|
||||
print(f"\nFAIL: {len(failures)} documentation check(s) failed:")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
|
||||
print("\nDocumentation tests complete!")
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -5,20 +5,32 @@ Test Entity Animation
|
|||
|
||||
Isolated test for entity position animation.
|
||||
No perspective, just basic movement in a square pattern.
|
||||
|
||||
Headless: mcrfpy.step(dt) is the only clock, so the waypoint sequence is driven
|
||||
explicitly instead of by keyboard input + a free-running game loop (#350/#341).
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
failures = []
|
||||
|
||||
def check(condition, message):
|
||||
if not condition:
|
||||
print(f"FAIL: {message}")
|
||||
failures.append(message)
|
||||
return condition
|
||||
|
||||
# Create scene
|
||||
test_anim = mcrfpy.Scene("test_anim")
|
||||
|
||||
# Create simple grid
|
||||
grid = mcrfpy.Grid(grid_w=15, grid_h=15)
|
||||
grid = mcrfpy.Grid(grid_size=(15, 15), pos=(100, 100), size=(450, 450))
|
||||
grid.fill_color = mcrfpy.Color(20, 20, 30)
|
||||
|
||||
# Add a color layer for cell coloring
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
# Add a color layer for cell coloring (GridPoint.color is gone; use a ColorLayer)
|
||||
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||
grid.grid_data.add_layer(color_layer)
|
||||
|
||||
# Initialize all cells as walkable floors
|
||||
for y in range(15):
|
||||
|
|
@ -26,7 +38,7 @@ for y in range(15):
|
|||
cell = grid.at(x, y)
|
||||
cell.walkable = True
|
||||
cell.transparent = True
|
||||
color_layer.set(x, y, mcrfpy.Color(100, 100, 120))
|
||||
color_layer.set((x, y), mcrfpy.Color(100, 100, 120))
|
||||
|
||||
# Mark the path we'll follow with different color
|
||||
path_cells = [(5,5), (6,5), (7,5), (8,5), (9,5), (10,5),
|
||||
|
|
@ -35,17 +47,16 @@ path_cells = [(5,5), (6,5), (7,5), (8,5), (9,5), (10,5),
|
|||
(5,9), (5,8), (5,7), (5,6)]
|
||||
|
||||
for x, y in path_cells:
|
||||
color_layer.set(x, y, mcrfpy.Color(120, 120, 150))
|
||||
color_layer.set((x, y), mcrfpy.Color(120, 120, 150))
|
||||
|
||||
# Create entity at start position
|
||||
entity = mcrfpy.Entity((5, 5), grid=grid)
|
||||
entity = mcrfpy.Entity(grid_pos=(5, 5))
|
||||
entity.sprite_index = 64 # @
|
||||
grid.entities.append(entity)
|
||||
|
||||
# UI setup
|
||||
ui = test_anim.children
|
||||
ui.append(grid)
|
||||
grid.pos = (100, 100)
|
||||
grid.size = (450, 450) # 15 * 30 pixels per cell
|
||||
|
||||
# Title
|
||||
title = mcrfpy.Caption(pos=(200, 20), text="Entity Animation Test - Square Path")
|
||||
|
|
@ -53,12 +64,13 @@ title.fill_color = mcrfpy.Color(255, 255, 255)
|
|||
ui.append(title)
|
||||
|
||||
# Status display
|
||||
status = mcrfpy.Caption(pos=(100, 50), text="Press SPACE to start animation | Q to quit")
|
||||
status = mcrfpy.Caption(pos=(100, 50), text="Animating square path")
|
||||
status.fill_color = mcrfpy.Color(200, 200, 200)
|
||||
ui.append(status)
|
||||
|
||||
# Position display
|
||||
pos_display = mcrfpy.Caption(pos=(100, 70), text=f"Entity Position: ({entity.x:.2f}, {entity.y:.2f})")
|
||||
pos_display = mcrfpy.Caption(pos=(100, 70),
|
||||
text=f"Entity Position: ({entity.draw_pos.x:.2f}, {entity.draw_pos.y:.2f})")
|
||||
pos_display.fill_color = mcrfpy.Color(255, 255, 100)
|
||||
ui.append(pos_display)
|
||||
|
||||
|
|
@ -67,139 +79,118 @@ anim_info = mcrfpy.Caption(pos=(400, 70), text="Animation: Not started")
|
|||
anim_info.fill_color = mcrfpy.Color(100, 255, 255)
|
||||
ui.append(anim_info)
|
||||
|
||||
# Debug info
|
||||
debug_info = mcrfpy.Caption(pos=(100, 570), text="Debug: Waiting...")
|
||||
debug_info.fill_color = mcrfpy.Color(150, 150, 150)
|
||||
ui.append(debug_info)
|
||||
|
||||
# Animation state
|
||||
current_waypoint = 0
|
||||
animating = False
|
||||
waypoints = [(5,5), (10,5), (10,10), (5,10), (5,5)]
|
||||
completed = [] # (property, final_value) tuples from animation callbacks
|
||||
|
||||
def update_position_display(timer, runtime):
|
||||
"""Update position display every 200ms"""
|
||||
pos_display.text = f"Entity Position: ({entity.x:.2f}, {entity.y:.2f})"
|
||||
def on_anim_done(target, prop, value):
|
||||
"""Animation completion callback: (target, property, final_value)"""
|
||||
completed.append((prop, value))
|
||||
|
||||
# Check if entity is at expected position
|
||||
if animating and current_waypoint > 0:
|
||||
target = waypoints[current_waypoint - 1]
|
||||
distance = ((entity.x - target[0])**2 + (entity.y - target[1])**2)**0.5
|
||||
debug_info.text = f"Debug: Distance to target {target}: {distance:.3f}"
|
||||
def animate_to_waypoint(index):
|
||||
"""Animate the entity's draw position to waypoints[index]."""
|
||||
target_x, target_y = waypoints[index]
|
||||
duration = 2.0
|
||||
|
||||
def animate_to_next_waypoint():
|
||||
"""Animate to the next waypoint"""
|
||||
global current_waypoint, animating
|
||||
|
||||
if current_waypoint >= len(waypoints):
|
||||
status.text = "Animation complete! Press SPACE to restart"
|
||||
anim_info.text = "Animation: Complete"
|
||||
animating = False
|
||||
current_waypoint = 0
|
||||
return
|
||||
|
||||
target_x, target_y = waypoints[current_waypoint]
|
||||
|
||||
# Log what we're doing
|
||||
print(f"Animating from ({entity.x}, {entity.y}) to ({target_x}, {target_y})")
|
||||
|
||||
# Update status
|
||||
status.text = f"Moving to waypoint {current_waypoint + 1}/{len(waypoints)}: ({target_x}, {target_y})"
|
||||
print(f"Animating from ({entity.draw_pos.x}, {entity.draw_pos.y}) to ({target_x}, {target_y})")
|
||||
status.text = f"Moving to waypoint {index + 1}/{len(waypoints)}: ({target_x}, {target_y})"
|
||||
anim_info.text = f"Animation: Active (target: {target_x}, {target_y})"
|
||||
|
||||
# Create animations - ensure we're using floats
|
||||
duration = 2.0 # 2 seconds per segment
|
||||
|
||||
# Try different approaches to see what works
|
||||
|
||||
# Approach 1: Direct property animation
|
||||
anim_x = mcrfpy.Animation("x", float(target_x), duration, "linear")
|
||||
anim_y = mcrfpy.Animation("y", float(target_y), duration, "linear")
|
||||
|
||||
# Start animations
|
||||
anim_x.start(entity)
|
||||
anim_y.start(entity)
|
||||
|
||||
# Log animation details
|
||||
print(f"Started animations: x to {float(target_x)}, y to {float(target_y)}, duration: {duration}s")
|
||||
|
||||
current_waypoint += 1
|
||||
|
||||
# Schedule next waypoint
|
||||
global next_waypoint_timer
|
||||
next_waypoint_timer = mcrfpy.Timer("next_waypoint", lambda t, r: animate_to_next_waypoint(), int(duration * 1000 + 100), once=True)
|
||||
# 'draw_x'/'draw_y' are the tile-space draw coordinates ('x'/'y' are aliases).
|
||||
# mcrfpy.Animation is no longer exported; animations are created via .animate().
|
||||
entity.animate("draw_x", float(target_x), duration, mcrfpy.Easing.LINEAR,
|
||||
callback=on_anim_done)
|
||||
entity.animate("draw_y", float(target_y), duration, mcrfpy.Easing.LINEAR,
|
||||
callback=on_anim_done)
|
||||
return duration
|
||||
|
||||
def start_animation():
|
||||
"""Start or restart the animation sequence"""
|
||||
global current_waypoint, animating
|
||||
|
||||
# Reset entity position
|
||||
entity.x = 5
|
||||
entity.y = 5
|
||||
|
||||
# Reset state
|
||||
current_waypoint = 0
|
||||
animating = True
|
||||
|
||||
print("Starting animation sequence...")
|
||||
|
||||
# Start first animation
|
||||
animate_to_next_waypoint()
|
||||
def step_seconds(seconds, dt=0.1):
|
||||
"""Advance the headless clock; the engine never advances time on its own."""
|
||||
for _ in range(int(round(seconds / dt))):
|
||||
mcrfpy.step(dt)
|
||||
|
||||
def test_immediate_position():
|
||||
"""Test setting position directly"""
|
||||
print(f"Before: entity at ({entity.x}, {entity.y})")
|
||||
entity.x = 7
|
||||
entity.y = 7
|
||||
print(f"After direct set: entity at ({entity.x}, {entity.y})")
|
||||
|
||||
# Try with animation to same position
|
||||
anim_x = mcrfpy.Animation("x", 9.0, 1.0, "linear")
|
||||
anim_x.start(entity)
|
||||
print("Started animation to x=9.0")
|
||||
"""Test setting position directly (no animation)."""
|
||||
# #295: grid_pos (logical cell) is DECOUPLED from draw_pos (visual position).
|
||||
# Setting one does not move the other; they are assigned independently.
|
||||
entity.grid_pos = (7, 7)
|
||||
check(tuple(entity.cell_pos) == (7, 7),
|
||||
f"direct grid_pos set: expected cell (7,7), got {tuple(entity.cell_pos)}")
|
||||
check((entity.draw_pos.x, entity.draw_pos.y) == (5.0, 5.0),
|
||||
f"grid_pos is decoupled from draw_pos; draw_pos should be unchanged at "
|
||||
f"(5,5), got {entity.draw_pos}")
|
||||
|
||||
# Input handler
|
||||
def handle_input(key, state):
|
||||
if state != mcrfpy.InputState.PRESSED:
|
||||
return
|
||||
entity.draw_pos = (7.0, 7.0)
|
||||
check((entity.draw_pos.x, entity.draw_pos.y) == (7.0, 7.0),
|
||||
f"direct draw_pos set: expected (7.0, 7.0), got {entity.draw_pos}")
|
||||
|
||||
if key == mcrfpy.Key.Q:
|
||||
print("Exiting test...")
|
||||
sys.exit(0)
|
||||
elif key == mcrfpy.Key.SPACE:
|
||||
if not animating:
|
||||
start_animation()
|
||||
else:
|
||||
print("Animation already in progress!")
|
||||
elif key == mcrfpy.Key.T:
|
||||
# Test immediate position change
|
||||
test_immediate_position()
|
||||
elif key == mcrfpy.Key.R:
|
||||
# Reset position
|
||||
entity.x = 5
|
||||
entity.y = 5
|
||||
print(f"Reset entity to ({entity.x}, {entity.y})")
|
||||
|
||||
# Set scene
|
||||
test_anim.activate()
|
||||
test_anim.on_key = handle_input
|
||||
|
||||
# Start position update timer
|
||||
update_pos_timer = mcrfpy.Timer("update_pos", update_position_display, 200)
|
||||
|
||||
# No perspective (omniscient view)
|
||||
grid.perspective = -1
|
||||
# Animation to a new x, driven by explicit steps
|
||||
entity.animate("draw_x", 9.0, 1.0, mcrfpy.Easing.LINEAR)
|
||||
step_seconds(0.5)
|
||||
mid_x = entity.draw_pos.x
|
||||
check(7.0 < mid_x < 9.0,
|
||||
f"linear animation should be mid-flight at t=0.5s, draw_x={mid_x}")
|
||||
step_seconds(0.6)
|
||||
check(abs(entity.draw_pos.x - 9.0) < 0.01,
|
||||
f"animation should land on draw_x=9.0, got {entity.draw_pos.x}")
|
||||
|
||||
print("Entity Animation Test")
|
||||
print("====================")
|
||||
print("This test animates an entity in a square pattern:")
|
||||
print("(5,5) → (10,5) → (10,10) → (5,10) → (5,5)")
|
||||
print("(5,5) -> (10,5) -> (10,10) -> (5,10) -> (5,5)")
|
||||
print()
|
||||
print("Controls:")
|
||||
print(" SPACE - Start animation")
|
||||
print(" T - Test immediate position change")
|
||||
print(" R - Reset position to (5,5)")
|
||||
print(" Q - Quit")
|
||||
print()
|
||||
print("The position display updates every 200ms")
|
||||
print("Watch the console for animation logs")
|
||||
|
||||
test_anim.activate()
|
||||
grid.perspective = None # omniscient view (perspective takes an Entity or None now)
|
||||
|
||||
# --- Direct position assignment ---------------------------------------------
|
||||
test_immediate_position()
|
||||
|
||||
# --- Square-path waypoint animation -----------------------------------------
|
||||
entity.grid_pos = (5, 5)
|
||||
entity.draw_pos = (5.0, 5.0) # decoupled from grid_pos; reset both
|
||||
completed.clear()
|
||||
|
||||
for i, (wx, wy) in enumerate(waypoints):
|
||||
start = (entity.draw_pos.x, entity.draw_pos.y)
|
||||
duration = animate_to_waypoint(i)
|
||||
|
||||
# Halfway through: entity must be strictly between start and target on any
|
||||
# axis that actually changes (proves interpolation, not a snap).
|
||||
step_seconds(duration / 2)
|
||||
half = (entity.draw_pos.x, entity.draw_pos.y)
|
||||
for axis, s, t, h in (("x", start[0], wx, half[0]), ("y", start[1], wy, half[1])):
|
||||
if s != t:
|
||||
check(min(s, t) < h < max(s, t),
|
||||
f"waypoint {i}: draw_{axis} should be interpolating between "
|
||||
f"{s} and {t} at half-time, got {h}")
|
||||
|
||||
# Finish the segment (plus a little slack for the final step)
|
||||
step_seconds(duration / 2 + 0.2)
|
||||
end = entity.draw_pos
|
||||
check(abs(end.x - wx) < 0.01 and abs(end.y - wy) < 0.01,
|
||||
f"waypoint {i}: expected draw_pos ({wx}, {wy}), got ({end.x}, {end.y})")
|
||||
|
||||
pos_display.text = f"Entity Position: ({end.x:.2f}, {end.y:.2f})"
|
||||
|
||||
anim_info.text = "Animation: Complete"
|
||||
|
||||
# Each waypoint fires two completion callbacks (draw_x and draw_y)
|
||||
check(len(completed) == 2 * len(waypoints),
|
||||
f"expected {2 * len(waypoints)} animation callbacks, got {len(completed)}")
|
||||
|
||||
# The entity ends where it started -- a closed square
|
||||
check(abs(entity.draw_pos.x - 5.0) < 0.01 and abs(entity.draw_pos.y - 5.0) < 0.01,
|
||||
f"square path should end at (5, 5), got {entity.draw_pos}")
|
||||
|
||||
# Animating draw_pos does not move the entity's logical cell (#313 contract:
|
||||
# draw_pos is the visual position, cell_pos/grid_pos is the authoritative cell).
|
||||
check(tuple(entity.cell_pos) == (5, 5),
|
||||
f"logical cell should still be (5,5), got {tuple(entity.cell_pos)}")
|
||||
|
||||
if failures:
|
||||
print(f"\n{len(failures)} check(s) failed")
|
||||
sys.exit(1)
|
||||
|
||||
print("\nPASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,24 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test the new Entity.path_to() method"""
|
||||
"""Test the Entity.path_to() method"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
print("Testing Entity.path_to() method...")
|
||||
print("=" * 50)
|
||||
|
||||
failures = []
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
if condition:
|
||||
print(f" PASS {label}")
|
||||
else:
|
||||
print(f" FAIL {label}: {detail}")
|
||||
failures.append(label)
|
||||
|
||||
# Create scene and grid
|
||||
path_test = mcrfpy.Scene("path_test")
|
||||
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
|
||||
# Set up a simple map with some walls
|
||||
for y in range(10):
|
||||
|
|
@ -24,48 +34,76 @@ for x, y in walls:
|
|||
# Create entity
|
||||
entity = mcrfpy.Entity((2, 2), grid=grid)
|
||||
|
||||
print(f"Entity at: ({entity.x}, {entity.y})")
|
||||
# NOTE: entity.x/.y are pixel draw coords now; the logical cell is entity.cell_pos.
|
||||
start = (int(entity.cell_pos.x), int(entity.cell_pos.y))
|
||||
print(f"Entity at cell: {start}")
|
||||
check("entity starts at (2, 2)", start == (2, 2), f"got {start}")
|
||||
|
||||
# Test 1: Simple path
|
||||
|
||||
def validate_path(path, target):
|
||||
"""A path must be a contiguous, wall-free walk ending on the target."""
|
||||
if not path:
|
||||
return "path is empty"
|
||||
if tuple(path[-1]) != target:
|
||||
return f"path does not end at {target}: {path[-1]}"
|
||||
prev = start
|
||||
for step in path:
|
||||
step = tuple(step)
|
||||
if step in walls:
|
||||
return f"path walks through wall {step}"
|
||||
dx, dy = abs(step[0] - prev[0]), abs(step[1] - prev[1])
|
||||
if max(dx, dy) != 1:
|
||||
return f"non-adjacent step {prev} -> {step}"
|
||||
prev = step
|
||||
return None
|
||||
|
||||
|
||||
# Test 1: Simple path (positional x, y)
|
||||
print("\nTest 1: Path to (6, 6)")
|
||||
try:
|
||||
path = entity.path_to(6, 6)
|
||||
print(f" Path: {path}")
|
||||
print(f" Length: {len(path)} steps")
|
||||
print(" ✓ SUCCESS")
|
||||
except Exception as e:
|
||||
print(f" ✗ FAILED: {e}")
|
||||
path = entity.path_to(6, 6)
|
||||
print(f" Path: {path}")
|
||||
err = validate_path(path, (6, 6))
|
||||
check("path_to(6, 6) returns a valid walk to (6, 6)", err is None, err or "")
|
||||
|
||||
# Test 2: Path with target_x/target_y keywords
|
||||
print("\nTest 2: Path using keyword arguments")
|
||||
try:
|
||||
path = entity.path_to(target_x=7, target_y=7)
|
||||
print(f" Path: {path}")
|
||||
print(f" Length: {len(path)} steps")
|
||||
print(" ✓ SUCCESS")
|
||||
except Exception as e:
|
||||
print(f" ✗ FAILED: {e}")
|
||||
# Test 2: Path using keyword arguments.
|
||||
# API CHANGE: the old target_x=/target_y= keywords are gone; the position-spec
|
||||
# parser now accepts pos=(x, y) (or a bare tuple/Vector).
|
||||
print("\nTest 2: Path using keyword argument pos=(7, 7)")
|
||||
path = entity.path_to(pos=(7, 7))
|
||||
print(f" Path: {path}")
|
||||
err = validate_path(path, (7, 7))
|
||||
check("path_to(pos=(7, 7)) returns a valid walk to (7, 7)", err is None, err or "")
|
||||
|
||||
# Test 3: Path to unreachable location
|
||||
# Test 3: Path to current position is empty (already there)
|
||||
print("\nTest 3: Path to current position")
|
||||
try:
|
||||
path = entity.path_to(2, 2)
|
||||
print(f" Path: {path}")
|
||||
print(f" Length: {len(path)} steps")
|
||||
print(" ✓ SUCCESS")
|
||||
except Exception as e:
|
||||
print(f" ✗ FAILED: {e}")
|
||||
path = entity.path_to(2, 2)
|
||||
print(f" Path: {path}")
|
||||
check("path_to(own position) is empty", path == [], f"got {path}")
|
||||
|
||||
# Test 4: Error cases
|
||||
print("\nTest 4: Error handling")
|
||||
try:
|
||||
# Out of bounds
|
||||
path = entity.path_to(15, 15)
|
||||
print(" ✗ Should have failed for out of bounds")
|
||||
entity.path_to(15, 15)
|
||||
check("out-of-bounds target raises ValueError", False, "no exception raised")
|
||||
except ValueError as e:
|
||||
print(f" ✓ Correctly caught out of bounds: {e}")
|
||||
check("out-of-bounds target raises ValueError", True)
|
||||
print(f" ({e})")
|
||||
except Exception as e:
|
||||
print(f" ✗ Wrong exception type: {e}")
|
||||
check("out-of-bounds target raises ValueError", False,
|
||||
f"wrong exception type {type(e).__name__}: {e}")
|
||||
|
||||
# Test 5: Unreachable target -> empty path (wall off a cell completely)
|
||||
print("\nTest 5: Unreachable target")
|
||||
for x, y in [(8, 0), (8, 1), (9, 1)]:
|
||||
grid.at(x, y).walkable = False
|
||||
path = entity.path_to(9, 0)
|
||||
print(f" Path: {path}")
|
||||
check("walled-off target yields empty path", path == [], f"got {path}")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("Entity.path_to() testing complete!")
|
||||
if failures:
|
||||
print(f"FAILED: {len(failures)} check(s): {failures}")
|
||||
sys.exit(1)
|
||||
print("Entity.path_to() testing complete!")
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -2,25 +2,36 @@
|
|||
"""Test edge cases for Entity.path_to() method"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
failures = []
|
||||
|
||||
def check(condition, message):
|
||||
"""Record a pass/fail instead of just printing it."""
|
||||
if condition:
|
||||
print(" [ok] %s" % message)
|
||||
else:
|
||||
print(" [FAIL] %s" % message)
|
||||
failures.append(message)
|
||||
|
||||
print("Testing Entity.path_to() edge cases...")
|
||||
print("=" * 50)
|
||||
|
||||
# Test 1: Entity without grid
|
||||
print("Test 1: Entity not in grid")
|
||||
entity = mcrfpy.Entity(grid_pos=(5, 5))
|
||||
try:
|
||||
entity = mcrfpy.Entity((5, 5))
|
||||
path = entity.path_to(8, 8)
|
||||
print(" ✗ Should have failed for entity not in grid")
|
||||
check(False, "path_to() on a grid-less entity should raise ValueError, got %r" % (path,))
|
||||
except ValueError as e:
|
||||
print(f" ✓ Correctly caught no grid error: {e}")
|
||||
check(True, "correctly raised ValueError with no grid: %s" % e)
|
||||
except Exception as e:
|
||||
print(f" ✗ Wrong exception type: {e}")
|
||||
check(False, "wrong exception type: %s: %s" % (type(e).__name__, e))
|
||||
|
||||
# Test 2: Entity in grid with walls blocking path
|
||||
print("\nTest 2: Completely blocked path")
|
||||
blocked_test = mcrfpy.Scene("blocked_test")
|
||||
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
|
||||
grid = mcrfpy.Grid(grid_size=(5, 5))
|
||||
|
||||
# Make all tiles walkable first
|
||||
for y in range(5):
|
||||
|
|
@ -31,25 +42,28 @@ for y in range(5):
|
|||
for x in range(5):
|
||||
grid.at(x, 2).walkable = False
|
||||
|
||||
entity = mcrfpy.Entity((1, 1), grid=grid)
|
||||
entity = mcrfpy.Entity(grid_pos=(1, 1), grid=grid)
|
||||
|
||||
try:
|
||||
path = entity.path_to(1, 4)
|
||||
if path:
|
||||
print(f" Path found: {path}")
|
||||
else:
|
||||
print(" ✓ No path found (empty list returned)")
|
||||
except Exception as e:
|
||||
print(f" ✗ Unexpected error: {e}")
|
||||
path = entity.path_to(1, 4)
|
||||
check(path == [], "no path across the full-width wall, got %r" % (path,))
|
||||
|
||||
# Test 3: Alternative parameter parsing
|
||||
# Test 3: Alternative parameter parsing (keyword x/y)
|
||||
print("\nTest 3: Alternative parameter names")
|
||||
try:
|
||||
path = entity.path_to(x=3, y=1)
|
||||
print(f" Path with x/y params: {path}")
|
||||
print(" ✓ SUCCESS")
|
||||
except Exception as e:
|
||||
print(f" ✗ FAILED: {e}")
|
||||
path = entity.path_to(x=3, y=1)
|
||||
check(path == [(2, 1), (3, 1)],
|
||||
"path_to(x=, y=) walked the open row: %r" % (path,))
|
||||
|
||||
# The same target via positional args must agree with the keyword form.
|
||||
check(entity.path_to(3, 1) == path,
|
||||
"positional path_to(3, 1) matches the keyword form")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("Edge case testing complete!")
|
||||
if failures:
|
||||
print("Edge case testing FAILED (%d):" % len(failures))
|
||||
for f in failures:
|
||||
print(" - %s" % f)
|
||||
sys.exit(1)
|
||||
|
||||
print("Edge case testing complete!")
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reproduce the exact failure from dijkstra_demo_final.py"""
|
||||
"""Reproduce the exact failure from dijkstra_demo_final.py
|
||||
|
||||
Original intent: a bulk per-cell mutation loop (walkable/transparent/color over
|
||||
every cell of a Grid) appeared to set a Python exception that was NOT raised at
|
||||
its source, but instead surfaced later on an unrelated line (the `walls.append`
|
||||
loop). This test drives that exact pattern and asserts that:
|
||||
1. the pattern completes without raising, and
|
||||
2. no *stale/pending* exception leaks out onto a later, unrelated statement.
|
||||
|
||||
API note: GridPoint no longer has `.color` -- per-cell color now lives on a
|
||||
ColorLayer (grid.add_layer(mcrfpy.ColorLayer(...)); layer.set((x, y), color)).
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
print("Reproducing exact failure pattern...")
|
||||
print("=" * 50)
|
||||
|
|
@ -10,87 +22,123 @@ print("=" * 50)
|
|||
WALL_COLOR = mcrfpy.Color(60, 30, 30)
|
||||
FLOOR_COLOR = mcrfpy.Color(200, 200, 220)
|
||||
|
||||
GRID_W, GRID_H = 25, 15
|
||||
|
||||
failures = []
|
||||
|
||||
def test_exact_pattern():
|
||||
"""Exact code from dijkstra_demo_final.py"""
|
||||
"""Exact code from dijkstra_demo_final.py (ported to the current API)"""
|
||||
dijkstra_demo = mcrfpy.Scene("dijkstra_demo")
|
||||
|
||||
|
||||
# Create grid
|
||||
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
||||
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H))
|
||||
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||
|
||||
|
||||
# Per-cell color now lives on a ColorLayer, not on GridPoint
|
||||
color_layer = mcrfpy.ColorLayer(name="floor")
|
||||
grid.add_layer(color_layer)
|
||||
|
||||
# Initialize all as floor
|
||||
for y in range(15):
|
||||
for x in range(25):
|
||||
for y in range(GRID_H):
|
||||
for x in range(GRID_W):
|
||||
grid.at(x, y).walkable = True
|
||||
grid.at(x, y).transparent = True
|
||||
grid.at(x, y).color = FLOOR_COLOR
|
||||
|
||||
color_layer.set((x, y), FLOOR_COLOR)
|
||||
|
||||
# Create an interesting dungeon layout
|
||||
walls = []
|
||||
|
||||
|
||||
# Room walls
|
||||
# Top-left room
|
||||
for x in range(1, 8): walls.append((x, 1))
|
||||
|
||||
return grid, walls
|
||||
|
||||
return grid, color_layer, walls
|
||||
|
||||
print("Test 1: Running exact pattern...")
|
||||
try:
|
||||
grid, walls = test_exact_pattern()
|
||||
print(f" ✓ Success! Created {len(walls)} walls")
|
||||
grid, color_layer, walls = test_exact_pattern()
|
||||
print(f" OK Success! Created {len(walls)} walls")
|
||||
if len(walls) != 7:
|
||||
failures.append(f"expected 7 walls, got {len(walls)}")
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed: {type(e).__name__}: {e}")
|
||||
failures.append(f"exact pattern raised {type(e).__name__}: {e}")
|
||||
print(f" FAIL Failed: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print()
|
||||
print("Test 2: Breaking it down step by step...")
|
||||
|
||||
def step(label, fn):
|
||||
"""Run a step; a raise here is a real failure, not something to swallow."""
|
||||
try:
|
||||
result = fn()
|
||||
print(f" OK {label}")
|
||||
return result
|
||||
except Exception as e:
|
||||
failures.append(f"{label} raised {type(e).__name__}: {e}")
|
||||
print(f" FAIL {label}: {type(e).__name__}: {e}")
|
||||
return None
|
||||
|
||||
# Step 1: Scene and grid
|
||||
try:
|
||||
test2 = mcrfpy.Scene("test2")
|
||||
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
||||
print(" ✓ Step 1: Scene and grid created")
|
||||
except Exception as e:
|
||||
print(f" ✗ Step 1 failed: {e}")
|
||||
def _step1():
|
||||
mcrfpy.Scene("test2")
|
||||
return mcrfpy.Grid(grid_size=(GRID_W, GRID_H))
|
||||
grid = step("Step 1: Scene and grid created", _step1)
|
||||
|
||||
# Step 2: Set fill_color
|
||||
try:
|
||||
def _step2():
|
||||
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||
print(" ✓ Step 2: fill_color set")
|
||||
except Exception as e:
|
||||
print(f" ✗ Step 2 failed: {e}")
|
||||
assert grid.fill_color == mcrfpy.Color(0, 0, 0)
|
||||
step("Step 2: fill_color set", _step2)
|
||||
|
||||
# Step 3: Nested loops with grid.at
|
||||
try:
|
||||
for y in range(15):
|
||||
for x in range(25):
|
||||
# Step 3: Nested loops with grid.at + ColorLayer.set (the suspect loop)
|
||||
layer = mcrfpy.ColorLayer(name="floor2")
|
||||
grid.add_layer(layer)
|
||||
def _step3():
|
||||
for y in range(GRID_H):
|
||||
for x in range(GRID_W):
|
||||
grid.at(x, y).walkable = True
|
||||
grid.at(x, y).transparent = True
|
||||
grid.at(x, y).color = FLOOR_COLOR
|
||||
print(" ✓ Step 3: Nested loops completed")
|
||||
except Exception as e:
|
||||
print(f" ✗ Step 3 failed: {e}")
|
||||
layer.set((x, y), FLOOR_COLOR)
|
||||
step("Step 3: Nested loops completed", _step3)
|
||||
|
||||
# The mutations must have actually landed (not silently dropped)
|
||||
if not (grid.at(0, 0).walkable and grid.at(GRID_W - 1, GRID_H - 1).walkable):
|
||||
failures.append("Step 3: walkable was not persisted by the nested loop")
|
||||
if not grid.at(GRID_W - 1, GRID_H - 1).transparent:
|
||||
failures.append("Step 3: transparent was not persisted by the nested loop")
|
||||
if layer.at((GRID_W - 1, GRID_H - 1)) != FLOOR_COLOR:
|
||||
failures.append("Step 3: ColorLayer color was not persisted by the nested loop")
|
||||
|
||||
# Step 4: Create walls list
|
||||
try:
|
||||
walls = []
|
||||
print(" ✓ Step 4: walls list created")
|
||||
except Exception as e:
|
||||
print(f" ✗ Step 4 failed: {e}")
|
||||
def _step4():
|
||||
return []
|
||||
walls = step("Step 4: walls list created", _step4)
|
||||
|
||||
# Step 5: The failing line
|
||||
try:
|
||||
# Step 5: The line that used to blow up with an exception raised by *step 3*.
|
||||
# This is the heart of the regression: after the nested loops, plain Python must
|
||||
# still work. A stale pending error would explode right here.
|
||||
def _step5():
|
||||
for x in range(1, 8): walls.append((x, 1))
|
||||
print(f" ✓ Step 5: For loop worked, walls = {walls}")
|
||||
except Exception as e:
|
||||
print(f" ✗ Step 5 failed: {type(e).__name__}: {e}")
|
||||
|
||||
# Check if exception was already pending
|
||||
import sys
|
||||
exc_info = sys.exc_info()
|
||||
print(f" Exception info: {exc_info}")
|
||||
return walls
|
||||
step("Step 5: For loop worked", _step5)
|
||||
|
||||
if walls != [(x, 1) for x in range(1, 8)]:
|
||||
failures.append(f"Step 5: walls list wrong: {walls}")
|
||||
|
||||
# Explicitly confirm no exception is left pending after the nested loops.
|
||||
if sys.exc_info()[0] is not None:
|
||||
failures.append(f"stale pending exception after loops: {sys.exc_info()}")
|
||||
|
||||
print()
|
||||
print("The error occurs at step 5, suggesting an exception was")
|
||||
print("set during the nested loops but not immediately raised.")
|
||||
if failures:
|
||||
print("FAIL: no exception may be deferred out of the nested cell-mutation loops")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
|
||||
print("The nested cell-mutation loops raise nothing, persist their writes, and")
|
||||
print("leave no pending exception to surface on a later line.")
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -9,10 +9,13 @@ Tests cover:
|
|||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import math
|
||||
import traceback
|
||||
|
||||
# Import the geometry module
|
||||
sys.path.insert(0, '/home/john/Development/McRogueFace/src/scripts')
|
||||
# Import the geometry module (src/scripts, relative to this test file)
|
||||
sys.path.insert(0, os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), '..', '..', 'src', 'scripts'))
|
||||
from geometry import (
|
||||
# Utilities
|
||||
distance, distance_squared, angle_between, normalize_angle,
|
||||
|
|
@ -601,4 +604,13 @@ def run_all_tests():
|
|||
print("=" * 50)
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_all_tests()
|
||||
# Exit contract (#350): headless --exec scripts must call sys.exit() explicitly.
|
||||
# Any failed assertion is a real failure -> report it and exit nonzero.
|
||||
try:
|
||||
run_all_tests()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
print("FAIL")
|
||||
sys.exit(1)
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -66,12 +66,16 @@ def test_apply_threshold_out_of_range():
|
|||
|
||||
|
||||
def test_apply_threshold_returns_self():
|
||||
"""apply_threshold returns self for chaining"""
|
||||
"""apply_threshold returns the GridData for chaining
|
||||
|
||||
Current contract (#313/#361): the apply_* methods live on GridData, and the
|
||||
Grid view forwards to it. They return the GridData ("self"), not the view.
|
||||
"""
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
|
||||
result = grid.apply_threshold(hmap, range=(0.0, 1.0), walkable=True)
|
||||
assert result is grid, "apply_threshold should return self"
|
||||
assert result is grid.grid_data, "apply_threshold should return the GridData (self)"
|
||||
print("PASS: test_apply_threshold_returns_self")
|
||||
|
||||
|
||||
|
|
@ -159,7 +163,7 @@ def test_apply_ranges_returns_self():
|
|||
result = grid.apply_ranges(hmap, [
|
||||
((0.0, 1.0), {"walkable": True}),
|
||||
])
|
||||
assert result is grid, "apply_ranges should return self"
|
||||
assert result is grid.grid_data, "apply_ranges should return the GridData (self)"
|
||||
print("PASS: test_apply_ranges_returns_self")
|
||||
|
||||
|
||||
|
|
@ -275,7 +279,7 @@ def test_chaining():
|
|||
((0.4, 0.6), {"walkable": True, "transparent": True}),
|
||||
]))
|
||||
|
||||
assert result is grid
|
||||
assert result is grid.grid_data
|
||||
print("PASS: test_chaining")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +1,48 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test Grid background color functionality"""
|
||||
"""Test Grid background color functionality
|
||||
|
||||
API notes (post-#313/#350):
|
||||
- Grid.background_color was renamed to Grid.fill_color ("Background fill color
|
||||
(Color). Drawn behind all tiles and entities."). Same property, new name.
|
||||
- grid.add_layer() takes a layer OBJECT and no keyword arguments; the layer's
|
||||
ctor takes the kwargs. ColorLayer.set() takes a (x, y) tuple.
|
||||
- Headless time only advances via mcrfpy.step(); timers never fire on their own.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
failures = []
|
||||
|
||||
def check(label, actual, expected):
|
||||
if actual == expected:
|
||||
print(f"+ {label}: {actual}")
|
||||
else:
|
||||
print(f"FAIL {label}: expected {expected}, got {actual}")
|
||||
failures.append(label)
|
||||
|
||||
def rgb(color):
|
||||
return (color.r, color.g, color.b)
|
||||
|
||||
def test_grid_background():
|
||||
"""Test Grid background color property"""
|
||||
print("Testing Grid Background Color...")
|
||||
|
||||
|
||||
# Create a test scene
|
||||
test = mcrfpy.Scene("test")
|
||||
ui = test.children
|
||||
|
||||
|
||||
# Create a grid with default background
|
||||
grid = mcrfpy.Grid(pos=(50, 50), size=(400, 300), grid_size=(20, 15))
|
||||
ui.append(grid)
|
||||
|
||||
# Add color layer for some tiles to see the background better
|
||||
color_layer = grid.add_layer("color", z_index=-1)
|
||||
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||
grid.add_layer(color_layer)
|
||||
for x in range(5, 15):
|
||||
for y in range(5, 10):
|
||||
color_layer.set(x, y, mcrfpy.Color(100, 150, 100))
|
||||
|
||||
color_layer.set((x, y), mcrfpy.Color(100, 150, 100))
|
||||
|
||||
# Add UI to show current background color
|
||||
info_frame = mcrfpy.Frame(pos=(500, 50), size=(200, 150),
|
||||
fill_color=mcrfpy.Color(40, 40, 40),
|
||||
|
|
@ -38,85 +59,108 @@ def test_grid_background():
|
|||
color_display.font_size = 12
|
||||
color_display.fill_color = mcrfpy.Color(200, 200, 200)
|
||||
info_frame.children.append(color_display)
|
||||
|
||||
|
||||
# Activate the scene
|
||||
test.activate()
|
||||
|
||||
|
||||
# Track which timer stages actually ran
|
||||
stages = []
|
||||
|
||||
def run_tests(timer, runtime):
|
||||
"""Run background color tests"""
|
||||
timer.stop()
|
||||
|
||||
stages.append("default")
|
||||
|
||||
print("\nTest 1: Default background color")
|
||||
default_color = grid.background_color
|
||||
print(f"Default: R={default_color.r}, G={default_color.g}, B={default_color.b}, A={default_color.a}")
|
||||
default_color = grid.fill_color
|
||||
print(f"Default: R={default_color.r}, G={default_color.g}, "
|
||||
f"B={default_color.b}, A={default_color.a}")
|
||||
color_display.text = f"R:{default_color.r} G:{default_color.g} B:{default_color.b}"
|
||||
|
||||
def test_set_color(timer, runtime):
|
||||
timer.stop()
|
||||
print("\nTest 2: Set background to blue")
|
||||
grid.background_color = mcrfpy.Color(20, 40, 100)
|
||||
new_color = grid.background_color
|
||||
print(f"+ Set to: R={new_color.r}, G={new_color.g}, B={new_color.b}")
|
||||
color_display.text = f"R:{new_color.r} G:{new_color.g} B:{new_color.b}"
|
||||
|
||||
def test_animation(timer, runtime):
|
||||
timer.stop()
|
||||
print("\nTest 3: Manual color cycling")
|
||||
# Manually change color to test property is working
|
||||
colors = [
|
||||
mcrfpy.Color(200, 20, 20), # Red
|
||||
mcrfpy.Color(20, 200, 20), # Green
|
||||
mcrfpy.Color(20, 20, 200), # Blue
|
||||
]
|
||||
def test_set_color(timer, runtime):
|
||||
timer.stop()
|
||||
stages.append("set")
|
||||
print("\nTest 2: Set background to blue")
|
||||
grid.fill_color = mcrfpy.Color(20, 40, 100)
|
||||
new_color = grid.fill_color
|
||||
check("set background to (20, 40, 100)", rgb(new_color), (20, 40, 100))
|
||||
color_display.text = f"R:{new_color.r} G:{new_color.g} B:{new_color.b}"
|
||||
|
||||
color_index = [0] # Use list to allow modification in nested function
|
||||
colors = [
|
||||
mcrfpy.Color(200, 20, 20), # Red
|
||||
mcrfpy.Color(20, 200, 20), # Green
|
||||
mcrfpy.Color(20, 20, 200), # Blue
|
||||
]
|
||||
|
||||
def cycle_red(t, r):
|
||||
t.stop()
|
||||
grid.background_color = colors[0]
|
||||
c = grid.background_color
|
||||
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
|
||||
print(f"+ Set to Red: R={c.r}, G={c.g}, B={c.b}")
|
||||
def test_animation(timer, runtime):
|
||||
timer.stop()
|
||||
stages.append("cycle")
|
||||
print("\nTest 3: Manual color cycling")
|
||||
|
||||
def cycle_green(t, r):
|
||||
t.stop()
|
||||
grid.background_color = colors[1]
|
||||
c = grid.background_color
|
||||
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
|
||||
print(f"+ Set to Green: R={c.r}, G={c.g}, B={c.b}")
|
||||
def cycle_red(t, r):
|
||||
t.stop()
|
||||
grid.fill_color = colors[0]
|
||||
c = grid.fill_color
|
||||
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
|
||||
check("cycle to Red", rgb(c), (200, 20, 20))
|
||||
|
||||
def cycle_blue(t, r):
|
||||
t.stop()
|
||||
grid.background_color = colors[2]
|
||||
c = grid.background_color
|
||||
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
|
||||
print(f"+ Set to Blue: R={c.r}, G={c.g}, B={c.b}")
|
||||
def cycle_green(t, r):
|
||||
t.stop()
|
||||
grid.fill_color = colors[1]
|
||||
c = grid.fill_color
|
||||
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
|
||||
check("cycle to Green", rgb(c), (20, 200, 20))
|
||||
|
||||
# Cycle through colors
|
||||
mcrfpy.Timer("cycle_0", cycle_red, 100, once=True)
|
||||
mcrfpy.Timer("cycle_1", cycle_green, 400, once=True)
|
||||
mcrfpy.Timer("cycle_2", cycle_blue, 700, once=True)
|
||||
def cycle_blue(t, r):
|
||||
t.stop()
|
||||
grid.fill_color = colors[2]
|
||||
c = grid.fill_color
|
||||
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
|
||||
check("cycle to Blue", rgb(c), (20, 20, 200))
|
||||
|
||||
def test_complete(timer, runtime):
|
||||
timer.stop()
|
||||
print("\nTest 4: Final color check")
|
||||
final_color = grid.background_color
|
||||
print(f"Final: R={final_color.r}, G={final_color.g}, B={final_color.b}")
|
||||
# Cycle through colors
|
||||
mcrfpy.Timer("cycle_0", cycle_red, 100, once=True)
|
||||
mcrfpy.Timer("cycle_1", cycle_green, 400, once=True)
|
||||
mcrfpy.Timer("cycle_2", cycle_blue, 700, once=True)
|
||||
|
||||
print("\n+ Grid background color tests completed!")
|
||||
print("- Default background color works")
|
||||
print("- Setting background color works")
|
||||
print("- Color cycling works")
|
||||
def test_complete(timer, runtime):
|
||||
timer.stop()
|
||||
stages.append("complete")
|
||||
print("\nTest 4: Final color check")
|
||||
final_color = grid.fill_color
|
||||
# The last color set by the cycle stage must have stuck.
|
||||
check("final color is Blue", rgb(final_color), (20, 20, 200))
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
# Schedule tests
|
||||
mcrfpy.Timer("test_set", test_set_color, 1000, once=True)
|
||||
mcrfpy.Timer("test_anim", test_animation, 2000, once=True)
|
||||
mcrfpy.Timer("complete", test_complete, 4500, once=True)
|
||||
|
||||
# Start tests
|
||||
# Schedule tests
|
||||
mcrfpy.Timer("run_tests", run_tests, 100, once=True)
|
||||
mcrfpy.Timer("test_set", test_set_color, 1000, once=True)
|
||||
mcrfpy.Timer("test_anim", test_animation, 2000, once=True)
|
||||
mcrfpy.Timer("complete", test_complete, 4500, once=True)
|
||||
|
||||
# Headless: mcrfpy.step() is the only clock. 5 simulated seconds at 50ms/step,
|
||||
# which covers the 4500ms 'complete' timer with margin.
|
||||
for _ in range(140):
|
||||
mcrfpy.step(0.05)
|
||||
|
||||
# Force a render so the grid (with its fill_color) actually goes through the
|
||||
# render path -- the property is a render-time input, not just a stored value.
|
||||
mcrfpy.automation.screenshot("test_grid_background.png")
|
||||
|
||||
for stage in ("default", "set", "cycle", "complete"):
|
||||
if stage not in stages:
|
||||
print(f"FAIL: timer stage '{stage}' never ran")
|
||||
failures.append(f"stage:{stage}")
|
||||
|
||||
if failures:
|
||||
print(f"\nFAIL: {len(failures)} check(s) failed: {failures}")
|
||||
sys.exit(1)
|
||||
|
||||
print("\n+ Grid background color tests completed!")
|
||||
print("- Default background color works")
|
||||
print("- Setting background color works")
|
||||
print("- Color cycling works")
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_grid_background()
|
||||
test_grid_background()
|
||||
|
|
|
|||
|
|
@ -1,124 +1,110 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test Grid constructor to isolate the PyArg bug"""
|
||||
"""Test Grid constructor to isolate the PyArg bug
|
||||
|
||||
Original intent: the Grid constructor used to set a Python exception without
|
||||
returning NULL, leaving a stale exception on the interpreter stack. The next
|
||||
Python operation (e.g. range(1)) then blew up with a cryptic
|
||||
"new style getargs format" error. This test constructs Grids of many sizes and
|
||||
verifies that (a) construction succeeds, (b) the resulting Grid is actually the
|
||||
size that was asked for, and (c) no stale exception is left behind.
|
||||
|
||||
API notes (current, post-#313/#361): the Grid constructor takes
|
||||
grid_size=(w, h); grid_w=/grid_h= are the legacy spelling and still work.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
failures = []
|
||||
|
||||
def check(cond, msg):
|
||||
if cond:
|
||||
print(f" PASS: {msg}")
|
||||
else:
|
||||
print(f" FAIL: {msg}")
|
||||
failures.append(msg)
|
||||
|
||||
def no_stale_exception(label):
|
||||
"""Any pending-but-unraised exception trips the next Python operation."""
|
||||
try:
|
||||
list(range(1))
|
||||
except Exception as e:
|
||||
check(False, f"{label}: stale exception after Grid creation: {type(e).__name__}: {e}")
|
||||
return False
|
||||
check(True, f"{label}: no stale exception after Grid creation")
|
||||
return True
|
||||
|
||||
print("Testing Grid constructor PyArg bug...")
|
||||
print("=" * 50)
|
||||
|
||||
# Test 1: Check if exception is set after Grid creation
|
||||
# Test 1: Check exception state after Grid creation
|
||||
print("Test 1: Check exception state after Grid creation")
|
||||
try:
|
||||
# Clear any existing exception
|
||||
sys.exc_clear() if hasattr(sys, 'exc_clear') else None
|
||||
|
||||
# Create grid with problematic dimensions
|
||||
print(" Creating Grid(grid_w=25, grid_h=15)...")
|
||||
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
||||
print(" Grid created successfully")
|
||||
|
||||
# Check if there's a pending exception
|
||||
exc = sys.exc_info()
|
||||
if exc[0] is not None:
|
||||
print(f" ⚠️ Pending exception detected: {exc}")
|
||||
|
||||
# Try to trigger the error
|
||||
print(" Calling range(1)...")
|
||||
for i in range(1):
|
||||
pass
|
||||
print(" ✓ range(1) worked")
|
||||
|
||||
print(" Creating Grid(grid_size=(25, 15))...")
|
||||
grid = mcrfpy.Grid(grid_size=(25, 15))
|
||||
check(True, "Grid(grid_size=(25, 15)) constructed")
|
||||
check((grid.grid_size.x, grid.grid_size.y) == (25, 15),
|
||||
f"grid_size is (25, 15), got ({grid.grid_size.x}, {grid.grid_size.y})")
|
||||
no_stale_exception("Test 1")
|
||||
except Exception as e:
|
||||
print(f" ✗ Exception: {type(e).__name__}: {e}")
|
||||
check(False, f"Grid(grid_size=(25, 15)) raised {type(e).__name__}: {e}")
|
||||
|
||||
print()
|
||||
|
||||
# Test 2: Try different Grid constructor patterns
|
||||
print("Test 2: Different Grid constructor calls")
|
||||
|
||||
# Pattern 1: Positional arguments
|
||||
try:
|
||||
print(" Trying Grid(25, 15)...")
|
||||
grid1 = mcrfpy.Grid(25, 15)
|
||||
for i in range(1): pass
|
||||
print(" ✓ Positional args worked")
|
||||
except Exception as e:
|
||||
print(f" ✗ Positional args failed: {e}")
|
||||
|
||||
# Pattern 2: Different size
|
||||
try:
|
||||
print(" Trying Grid(grid_w=24, grid_h=15)...")
|
||||
grid2 = mcrfpy.Grid(grid_w=24, grid_h=15)
|
||||
for i in range(1): pass
|
||||
print(" ✓ Size 24x15 worked")
|
||||
except Exception as e:
|
||||
print(f" ✗ Size 24x15 failed: {e}")
|
||||
|
||||
# Pattern 3: Check if it's specifically 25
|
||||
try:
|
||||
print(" Trying Grid(grid_w=26, grid_h=15)...")
|
||||
grid3 = mcrfpy.Grid(grid_w=26, grid_h=15)
|
||||
for i in range(1): pass
|
||||
print(" ✓ Size 26x15 worked")
|
||||
except Exception as e:
|
||||
print(f" ✗ Size 26x15 failed: {e}")
|
||||
# Test 2: legacy grid_w/grid_h spelling still constructs the same grid
|
||||
print("Test 2: Legacy grid_w=/grid_h= keywords")
|
||||
for w, h in [(24, 15), (25, 15), (26, 15)]:
|
||||
try:
|
||||
g = mcrfpy.Grid(grid_w=w, grid_h=h)
|
||||
ok = (g.grid_size.x, g.grid_size.y) == (w, h)
|
||||
check(ok, f"Grid(grid_w={w}, grid_h={h}) -> grid_size ({g.grid_size.x}, {g.grid_size.y})")
|
||||
no_stale_exception(f"Grid({w}, {h})")
|
||||
except Exception as e:
|
||||
check(False, f"Grid(grid_w={w}, grid_h={h}) raised {type(e).__name__}: {e}")
|
||||
|
||||
print()
|
||||
|
||||
# Test 3: Isolate the exact problem
|
||||
print("Test 3: Isolating the problem")
|
||||
# Test 3: Isolate the exact problem -- a sweep of sizes, each followed by a
|
||||
# plain Python operation that would explode if an exception were left pending.
|
||||
print("Test 3: Size sweep (construct, then immediately use the interpreter)")
|
||||
|
||||
def test_grid_creation(x, y):
|
||||
"""Test creating a grid and immediately using range()"""
|
||||
try:
|
||||
grid = mcrfpy.Grid(grid_w=x, grid_h=y)
|
||||
# Immediately test if exception is pending
|
||||
g = mcrfpy.Grid(grid_size=(x, y))
|
||||
# Immediately test if an exception is pending
|
||||
list(range(1))
|
||||
if (g.grid_size.x, g.grid_size.y) != (x, y):
|
||||
return False, f"wrong grid_size ({g.grid_size.x}, {g.grid_size.y})"
|
||||
return True, "Success"
|
||||
except Exception as e:
|
||||
return False, f"{type(e).__name__}: {e}"
|
||||
|
||||
# Test various sizes
|
||||
test_sizes = [(10, 10), (20, 20), (24, 15), (25, 14), (25, 15), (25, 16), (30, 30)]
|
||||
for x, y in test_sizes:
|
||||
success, msg = test_grid_creation(x, y)
|
||||
if success:
|
||||
print(f" Grid({x}, {y}): ✓")
|
||||
else:
|
||||
print(f" Grid({x}, {y}): ✗ {msg}")
|
||||
check(success, f"Grid({x}, {y}): {msg}")
|
||||
|
||||
print()
|
||||
|
||||
# Test 4: See if we can clear the exception
|
||||
print("Test 4: Exception clearing")
|
||||
# Test 4: bad arguments must raise properly (and not corrupt interpreter state)
|
||||
print("Test 4: Invalid arguments raise instead of leaving a pending exception")
|
||||
try:
|
||||
# Create the problematic grid
|
||||
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
||||
print(" Created Grid(25, 15)")
|
||||
|
||||
# Try to clear any pending exception
|
||||
try:
|
||||
# This should fail if there's a pending exception
|
||||
list(range(1))
|
||||
print(" No pending exception!")
|
||||
except:
|
||||
print(" ⚠️ Pending exception detected")
|
||||
# Clear it
|
||||
sys.exc_clear() if hasattr(sys, 'exc_clear') else None
|
||||
|
||||
# Try again
|
||||
try:
|
||||
list(range(1))
|
||||
print(" ✓ Exception cleared, range() works now")
|
||||
except:
|
||||
print(" ✗ Exception persists")
|
||||
|
||||
bad = mcrfpy.Grid(grid_size="not a size")
|
||||
check(False, "Grid(grid_size='not a size') should have raised")
|
||||
except (TypeError, ValueError) as e:
|
||||
check(True, f"Grid(grid_size='not a size') raised {type(e).__name__} as expected")
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed: {e}")
|
||||
check(False, f"Grid(grid_size='not a size') raised unexpected {type(e).__name__}: {e}")
|
||||
no_stale_exception("Test 4")
|
||||
|
||||
print()
|
||||
print("Conclusion: The Grid constructor is setting a Python exception")
|
||||
print("but not properly returning NULL to propagate it. This leaves")
|
||||
print("the exception on the stack, causing the next Python operation")
|
||||
print("to fail with the cryptic 'new style getargs format' error.")
|
||||
print("=" * 50)
|
||||
if failures:
|
||||
print(f"FAIL: {len(failures)} check(s) failed")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ print("Testing Grid features...")
|
|||
|
||||
# Create a texture first
|
||||
print("Loading texture...")
|
||||
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
|
||||
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||
print(f"Texture loaded: {texture}")
|
||||
|
||||
# Create grid
|
||||
|
|
@ -54,5 +54,9 @@ if not hasattr(pos, 'x'):
|
|||
|
||||
print(f"pos.x={pos.x}, pos.y={pos.y}")
|
||||
|
||||
if pos.x != 50 or pos.y != 100:
|
||||
print(f"FAIL: pos should be (50, 100), got ({pos.x}, {pos.y})")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS: Grid Vector properties work correctly!")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,17 @@
|
|||
"""Test grid iteration patterns to find the exact cause"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
failures = []
|
||||
|
||||
def check(condition, label):
|
||||
"""Record a failed check instead of silently printing a checkmark."""
|
||||
if condition:
|
||||
print(f" [ok] {label}")
|
||||
else:
|
||||
print(f" [FAIL] {label}")
|
||||
failures.append(label)
|
||||
|
||||
print("Testing grid iteration patterns...")
|
||||
print("=" * 50)
|
||||
|
|
@ -11,21 +22,23 @@ print("Test 1: Basic grid.at() calls")
|
|||
try:
|
||||
test1 = mcrfpy.Scene("test1")
|
||||
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
|
||||
|
||||
|
||||
# Single call
|
||||
grid.at(0, 0).walkable = True
|
||||
print(" ✓ Single grid.at() call works")
|
||||
|
||||
check(grid.at(0, 0).walkable is True, "Single grid.at() call works")
|
||||
|
||||
# Multiple calls
|
||||
grid.at(1, 1).walkable = True
|
||||
grid.at(2, 2).walkable = True
|
||||
print(" ✓ Multiple grid.at() calls work")
|
||||
|
||||
check(grid.at(1, 1).walkable and grid.at(2, 2).walkable,
|
||||
"Multiple grid.at() calls work")
|
||||
|
||||
# Now try a print
|
||||
print(" ✓ Print after grid.at() works")
|
||||
|
||||
print(" [ok] Print after grid.at() works")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||
print(f" [FAIL] Error: {type(e).__name__}: {e}")
|
||||
failures.append(f"Test 1: {type(e).__name__}: {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
|
@ -34,16 +47,18 @@ print("Test 2: Grid.at() in simple loop")
|
|||
try:
|
||||
test2 = mcrfpy.Scene("test2")
|
||||
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
|
||||
|
||||
|
||||
for i in range(3):
|
||||
grid.at(i, 0).walkable = True
|
||||
print(" ✓ Single loop with grid.at() works")
|
||||
|
||||
check(all(grid.at(i, 0).walkable for i in range(3)),
|
||||
"Single loop with grid.at() works")
|
||||
|
||||
# Print after loop
|
||||
print(" ✓ Print after loop works")
|
||||
|
||||
print(" [ok] Print after loop works")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||
print(f" [FAIL] Error: {type(e).__name__}: {e}")
|
||||
failures.append(f"Test 2: {type(e).__name__}: {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
|
@ -52,16 +67,18 @@ print("Test 3: Nested loops with grid.at()")
|
|||
try:
|
||||
test3 = mcrfpy.Scene("test3")
|
||||
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
|
||||
|
||||
|
||||
for y in range(3):
|
||||
for x in range(3):
|
||||
grid.at(x, y).walkable = True
|
||||
|
||||
print(" ✓ Nested loops with grid.at() work")
|
||||
print(" ✓ Print after nested loops works")
|
||||
|
||||
|
||||
check(all(grid.at(x, y).walkable for y in range(3) for x in range(3)),
|
||||
"Nested loops with grid.at() work")
|
||||
print(" [ok] Print after nested loops works")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||
print(f" [FAIL] Error: {type(e).__name__}: {e}")
|
||||
failures.append(f"Test 3: {type(e).__name__}: {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
|
@ -71,38 +88,46 @@ try:
|
|||
test4 = mcrfpy.Scene("test4")
|
||||
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
||||
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||
|
||||
|
||||
# Per-cell color moved off GridPoint onto ColorLayer (Grid layer rework);
|
||||
# GridPoint now exposes only entities/grid_pos/transparent/walkable.
|
||||
colors = mcrfpy.ColorLayer(name="cell_color")
|
||||
grid.add_layer(colors)
|
||||
|
||||
# This is the exact nested loop from the failing code
|
||||
for y in range(15):
|
||||
for x in range(25):
|
||||
grid.at(x, y).walkable = True
|
||||
grid.at(x, y).transparent = True
|
||||
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
|
||||
|
||||
print(" ✓ Full nested loop completed")
|
||||
|
||||
# This is where it fails
|
||||
colors.set((x, y), mcrfpy.Color(200, 200, 220))
|
||||
|
||||
check(all(grid.at(x, y).walkable and grid.at(x, y).transparent
|
||||
for y in range(15) for x in range(25)),
|
||||
"Full nested loop completed (375 cells set)")
|
||||
|
||||
# This is where it used to fail
|
||||
print(" About to test post-loop operations...")
|
||||
|
||||
|
||||
# Try different operations
|
||||
x = 5
|
||||
print(f" ✓ Variable assignment works: x={x}")
|
||||
|
||||
check(x == 5, f"Variable assignment works: x={x}")
|
||||
|
||||
lst = []
|
||||
print(f" ✓ List creation works: {lst}")
|
||||
|
||||
check(lst == [], f"List creation works: {lst}")
|
||||
|
||||
# The failing line
|
||||
for i in range(3): pass
|
||||
print(" ✓ Empty for loop works")
|
||||
|
||||
print(" [ok] Empty for loop works")
|
||||
|
||||
# With append
|
||||
for i in range(3): lst.append(i)
|
||||
print(f" ✓ For loop with append works: {lst}")
|
||||
|
||||
check(lst == [0, 1, 2], f"For loop with append works: {lst}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||
print(f" [FAIL] Error: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
failures.append(f"Test 4: {type(e).__name__}: {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
|
@ -111,28 +136,40 @@ print("Test 5: Testing grid.at() call limits")
|
|||
try:
|
||||
test5 = mcrfpy.Scene("test5")
|
||||
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
|
||||
|
||||
|
||||
count = 0
|
||||
for y in range(10):
|
||||
for x in range(10):
|
||||
grid.at(x, y).walkable = True
|
||||
count += 1
|
||||
|
||||
|
||||
# Test print every 10 calls
|
||||
if count % 10 == 0:
|
||||
print(f" Processed {count} cells...")
|
||||
|
||||
print(f" ✓ Processed all {count} cells")
|
||||
|
||||
|
||||
check(count == 100, f"Processed all {count} cells")
|
||||
check(all(grid.at(x, y).walkable for y in range(10) for x in range(10)),
|
||||
"All 100 cells still readable after the loop")
|
||||
|
||||
# Now test operations
|
||||
print(" Testing post-processing operations...")
|
||||
for i in range(3): pass
|
||||
print(" ✓ All operations work after 100 grid.at() calls")
|
||||
|
||||
print(" [ok] All operations work after 100 grid.at() calls")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||
print(f" [FAIL] Error: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
failures.append(f"Test 5: {type(e).__name__}: {e}")
|
||||
|
||||
print()
|
||||
print("Tests complete.")
|
||||
print("Tests complete.")
|
||||
|
||||
if failures:
|
||||
print(f"FAIL: {len(failures)} check(s) failed:")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue