Skip to content

Latest commit

 

History

History
313 lines (259 loc) · 12.6 KB

File metadata and controls

313 lines (259 loc) · 12.6 KB

Building Out-of-Tree Bunnyland Plugins

This repository is the reference pattern for a Bunnyland plugin that lives outside the main bunnyland-server tree.

Repository Layout

  • server/ is a Python package. It declares its plugin through package metadata.
  • web/ is a standalone Vite client. It consumes Bunnyland HTTP APIs and optional 3D projection fields from the plugin.
  • scripts/ runs local checks across both halves.

Keep plugin code out of bunnyland-server and client code out of bunnyland-web.

Plugin Entry Point

Bunnyland discovers installed plugins through the bunnyland.plugins entry-point group:

[project.entry-points."bunnyland.plugins"]
"vendor.forest" = "vendor_forest.plugin:plugin"

The target returns a Plugin with a globally stable id:

def plugin() -> Plugin:
    return Plugin(id="vendor.forest", name="Forest Visuals")

The server selects and orders discovered plugins from their declared dependencies.

ECS Contributions

Contribute ECS types through EcsContribution:

Plugin(
    id="bunnyland.3d",
    name="Bunnyland 3D",
    ecs=EcsContribution(
        components=(Transform3DComponent, Velocity3DComponent),
        systems=(Movement3DSystem,),
    ),
)

The main server does not need to import these classes directly. Once the plugin is loaded, Bunnyland persistence, schemas, patches, and systems can discover the types from the plugin contribution list.

Local Development

Build Bunnyland once at the exact server commit under test and pass that wheel artifact to every addon checkout. Install the wheel and addon in an isolated environment; do not add a server source checkout to Python's import path:

export BUNNYLAND_WHEEL=/artifacts/bunnyland-0.2.0-py3-none-any.whl
uv venv /tmp/vendor-forest-test
uv pip install --python /tmp/vendor-forest-test/bin/python \
  "${BUNNYLAND_WHEEL}[server]" pytest httpx
uv pip install --python /tmp/vendor-forest-test/bin/python --no-deps ./server
/tmp/vendor-forest-test/bin/python -m pytest server/tests

Using --no-deps for the addon install is intentional: the separately installed wheel is the server contract being tested. Ruff and the complete addon coverage suite should run in that same environment.

Vite clients should depend on shared web UI through @bunnyland/ui-web instead of copying assets from bunnyland-web. Use narrow imports such as @bunnyland/ui-web/api, @bunnyland/ui-web/play, @bunnyland/ui-web/theme, @bunnyland/ui-web/player-widgets, and @bunnyland/ui-web/admin-widgets so bundlers can tree shake player-only and admin-only surfaces independently.

Registering 3D Models

Visual plugins can publish models without changing the 3D addon or its web bundle. Declare bunnyland.3d as a dependency and register assets from an integration factory, which runs after the model registry is ready:

from pathlib import Path

from bunnyland.plugins import DependencyContribution, Plugin, RuntimeContribution
from bunnyland_3d import AssetSource, ModelAsset, register_models

ASSETS = Path(__file__).with_name("assets")

def install_visuals(actor):
    register_models(actor, "vendor.forest", [
        ModelAsset(
            key="vendor.forest/oak",
            source=AssetSource(root=ASSETS, path="oak.stl"),
            default_color="#628b4a",
            instanced=True,
            license="CC0-1.0",
        ),
    ])

def plugin() -> Plugin:
    return Plugin(
        id="vendor.forest",
        name="Forest Visuals",
        dependencies=DependencyContribution(requires=("bunnyland.3d",)),
        runtime=RuntimeContribution(integration_factories=(install_visuals,)),
    )

GLB, glTF (including local sidecars), OBJ/MTL, and ASCII or binary STL are accepted. The server validates plugin-owned roots, converts non-GLB inputs to content-addressed GLB, and publishes safe URLs through the play-scoped 3D asset manifest. Consult OpenAPI for the concrete operation and response schema. STL has no material convention, so set default_color when appearance matters. Use instanced=True only for static models; animated, skinned, and interactive props should remain individual ECS entities.

Bundled and plugin-provided models may expose optional accessory subtrees with the exact node name Variant.<key>, such as Variant.scout. Declare the matching keys in ModelAsset(variants=(...)). The player hides every Variant.* root before showing only the root selected by the resolved visual variant or Render3DComponent.variant_key. Missing, unknown, or undeclared variants leave the base model visible without accessories. Keep the variant root above all nodes that belong exclusively to that variant.

Character models may opt into the stock attention pose through semantic roles. Map look-focus to the head pivot and reach-left / reach-right to arm pivots. The bundled leporid falls back to its stable Head, Arm.L, and Arm.R names. The client turns only these pivots toward nearby or selected targets. Head focus remains active during movement; arm reach is applied only after movement stops and only inside a limited forward cone, so provider-owned meshes, materials, and animations remain replaceable without requiring browser-side plugin code.

Registering Skyboxes and Particle Systems

Visual plugins can also register bounded, declarative environment effects. Register them from an integration factory after declaring bunnyland.3d as a dependency:

from bunnyland_3d import (
    ParticleSystem3D,
    RoomParticleRule,
    RoomSkyboxRule,
    Skybox3D,
    register_particle_rules,
    register_particle_systems,
    register_skybox_rules,
    register_skyboxes,
)

def install_weather_effects(actor):
    register_skyboxes(actor, "vendor.weather", [
        Skybox3D(
            "vendor.weather/night",
            zenith_color="#071229",
            horizon_color="#29375c",
            cloud_count=0,
            star_opacity=0.85,
            star_count=180,
            portal_color="#78a7ff",
            locked_portal_color="#d65a66",
            portal_opacity=0.12,
            portal_effect="ripple",
            boundary_style="fence",
            boundary_color="#5f7048",
            bloom_strength=0.12,
            ssao_strength=0.2,
            depth_of_field_strength=0.04,
            lens_flare_strength=0.05,
            sun_ray_strength=0.08,
        ),
    ])
    register_particle_systems(actor, "vendor.weather", [
        ParticleSystem3D(
            "vendor.weather/snow",
            vertical_motion="fall",
            vertical_scale=0.7,
            lateral_wobble=0.16,
        ),
    ])
    register_skybox_rules(actor, "vendor.weather", [
        RoomSkyboxRule(
            "vendor.weather/night-rule",
            "vendor.weather/night",
            lambda world, room: is_clear_night(world, room),
            priority=20,
        ),
    ])
    register_particle_rules(actor, "vendor.weather", [
        RoomParticleRule(
            "vendor.weather/snowfall",
            "vendor.weather/snow",
            lambda _world, room: is_snowy(room),
            count=80,
            color="#e8f4ff",
        ),
    ])

Select the registered skybox with Environment3DComponent(skybox_preset="vendor.weather/night"), or the particle behavior with ParticleEmitter3DComponent(preset="vendor.weather/snow", ...). Keys must begin with the provider plugin id. The client receives only validated colors, counts, material modes, and motion parameters; registries do not transmit or execute plugin code in browsers. Room rules are evaluated during projection, so clock- or weather-dependent effects update without mutating room components. An explicit non-default skybox_preset takes precedence. Skybox definitions also own the default exit-aperture palette and may select the subtle ripple texture or none. This keeps the horizon, atmosphere, and portal treatment in one replaceable visual provider; uploaded equirectangular skies still replace the procedural sky texture itself. They also select boundary_style as auto, hedge, fence, or none and may provide boundary_color. Boundaries are one visual-only instanced batch with exit openings; they never add collision or room-content state.

The same atmosphere definition supplies bounded post-processing intent. Bloom is limited to 0.4, SSAO to 0.6, and depth of field, lens flare, and sun rays to 0.3, 0.4, and 0.4 respectively. Set an effect to zero when it does not suit the room. The player remains in control of execution: Off disables the post-processing target, Subtle scales every requested strength to 55%, and Full uses the registered strengths. The stock client combines enabled effects into one deferred fullscreen shader pass at 1× resolution; plugins cannot transmit shader source or force a player's chosen quality level.

The highest-priority matching particle rule replaces only the core ambient particle field; manually authored and plugin-owned emitters remain composable.

Registering Entity Effects

Use registered entity effects for reusable auras and timed spell feedback. Definitions contain one or more particle or lightning layers; active instances live on separate ECS entities linked to their target, so different sources can coexist:

from bunnyland_3d import (
    VisualEffectDefinition,
    VisualEffectParticleLayer,
    apply_visual_effect,
    register_visual_effects,
)

register_visual_effects(actor, "vendor.magic", [
    VisualEffectDefinition(
        "vendor.magic/healing",
        particle_layers=(
            VisualEffectParticleLayer(
                "vendor.magic/gold-rise",
                count=24,
                color="#ffd45c",
            ),
        ),
    ),
])
apply_visual_effect(actor, target.id, "vendor.magic/healing", 5, "vendor.magic/heal")

Reapplying the same effect and source refreshes its timer; a different source creates a separate instance. Pass -1 for a persistent effect. remove_visual_effect removes the matching effect/source pair explicitly. For component-driven state, register a VisualEffectStateRule; the every-tick effect system creates the persistent instance while its predicate matches and removes it when the predicate stops matching. The default entity-aura anchor works with procedural fallbacks and loaded models. Other semantic roles resolve against a loaded model and fall back to the entity root unless anchor_required is set.

Docker Packaging

An out-of-tree plugin should ship extension images, not forks of the main server and web repos. This repo provides that pattern with:

  • Dockerfile.server, which starts from the reviewed v0.2.1 Bunnyland server image by default and installs the plugin package into the existing server virtualenv.
  • Dockerfile.web, which starts from ghcr.io/thalismind/bunnyland-web:main by default and adds the built 3D welcome page, inspector, and player clients under /3d/.

Build the server image from this repo as the Docker context:

docker build -f Dockerfile.server -t bunnyland-3d-server .

Build the web image with the shared UI package supplied as a named BuildKit context:

docker build -f Dockerfile.web \
  --build-context bunnyland-ui-web=docker-image://ghcr.io/thalismind/bunnyland-ui-web:main \
  -t bunnyland-3d-web .

Installing the Python package makes its entry-point plugin discoverable. A default_enabled=True addon is applied automatically unless startup selects an explicit plugin subset; in that case include its stable plugin id. Do not add a module import flag or source path. The 3D web image serves the client index at /3d/, the admin inspector at /3d/admin.html, and the playable client at /3d/player.html.

CI

The included workflow builds its plugin test/runtime image from the published Bunnyland server image and explicitly pulls the fresh base. It installs the addon into the existing server virtualenv with --no-deps, runs the complete plugin suite, and publishes the composite only after tests pass. It never imports a sibling server checkout.

The web job builds the standalone client and captures 3D/2D screenshots with Playwright. It stores both downloaded canvas PNGs and full-page screenshots so the toolbar and side panel are visible in CI artifacts.

Design Rules

  • Do not edit bunnyland-server for plugin components, systems, or tests.
  • Do not edit bunnyland-web for this client.
  • Do not vendor shared web UI assets; import them from @bunnyland/ui-web.
  • Model singleton state as components.
  • Model repeatable relationships as edges in Bunnyland, not multiple components of the same type on one entity.
  • Keep collision and projection helpers pure where possible so out-of-tree tests do not need a running HTTP server.