L34 — Juice & readability
Goal
Make pickups feel good: bob, spin, flash the clear color, play SFX on collect.
In plain English
Juice = feedback that says “your input mattered” without new systems:
| Trick | Implementation |
|---|---|
| Bob/spin shards | sin(time) on Y + yaw |
| Collect flash | lerp clear color white for a few frames |
| SFX | wav64_play on collect |
| Rumble | joypad_set_rumble_active pulse (~0.18 s) if supported |
| Anim speed | walk clip rate follows stick |
What you will see
make -C lessons/l34-juiceScreen brightens briefly when you grab a shard. Full loop: title → play (island + snake + 3 shards) → audio/HUD → win.
Same draw/audio rules as earlier modules: audio_init(48000), island/snake/ shard scales, push/pop matrices, t3d_skeleton_use before skinned draw, FILTERS_RESAMPLE + opaque clear, free camYaw with move from the lagged eye (not player yaw), box soft-wall, snake -yaw.
Performance note
Juice is free if it’s math + existing draws. Particles (Tiny3D examples) are optional stretch.
Full lesson source
The blocks below are imported from the real repository files at build time (VitePress <<< snippets). They are not hand-copied into this markdown.
lessons/l34-juice/Makefile · lessons/l34-juice/src/main.c
lessons/l34-juice/Makefile
ROMNAME := l34_juice
ROM_TITLE := "L34 Juice"
USE_T3D := 1
include ../../common/lesson.mklessons/l34-juice/src/main.c
/**
* L34 — Juice & feedback
*
* Collect 3 — feel the juice on a small island. Third-person move, follow cam,
* idle/walk blend, audio, HUD, juice, title/pause/win states.
*
* docs/guide/m5/starshard-cove.md + L32–L36
*/
/**
* NOTE FOR NOOBS
* --------------
* This file is a teaching step toward the full game. For *line-by-line*
* commentary of the complete systems (audio + HUD + 8 shards + ranks),
* read:
* capstone/starshard-cove/src/main.c
*
* That capstone file is intentionally over-commented as the course map.
*
*/
#include <libdragon.h>
#include <t3d/t3d.h>
#include <t3d/t3dmodel.h>
#include <t3d/t3dskeleton.h>
#include <t3d/t3danim.h>
#include <stdio.h>
#include "ng_game.h"
#define FB_COUNT 3
#define SHARD_N 3
/*
* Mixer channel map (collect.wav is stereo → uses CH_COLLECT and CH_COLLECT+1).
* Never put mono UI/win on the stereo pair; never start XM on top of SFX.
* music.xm uses 8 channels from CH_BGM.
*/
#define CH_COLLECT 0 /* stereo collect → also 1 */
#define CH_UI 2
#define CH_WIN 3
#define CH_BGM 4 /* XM: 4..11 */
typedef enum {
ST_TITLE = 0,
ST_PLAY,
ST_PAUSE,
ST_WIN,
} GameState;
typedef struct {
fm_vec3_t pos;
bool alive;
T3DMat4FP *mat;
float pop; /* juice: 1→0 scale punch after spawn / for remaining */
} Shard;
static float dist_xz(fm_vec3_t a, fm_vec3_t b)
{
float dx = a.v[0] - b.v[0], dz = a.v[2] - b.v[2];
return sqrtf(dx * dx + dz * dz);
}
static const fm_vec3_t SHARD_SPAWNS[SHARD_N] = {
{{ 3.2f, 0.7f, 2.0f }},
{{ -2.8f, 0.7f, 2.6f }},
{{ 1.0f, 0.7f, -3.4f }},
};
int main(void)
{
debug_init_isviewer();
debug_init_usblog();
asset_init_compression(2);
dfs_init(DFS_DEFAULT_LOCATION);
/* FILTERS_RESAMPLE only — RESAMPLE_ANTIALIAS can leave a 1px flickering
* green/cyan line at the top of the framebuffer on some VI paths. */
display_init(RESOLUTION_320x240, DEPTH_16_BPP, FB_COUNT, GAMMA_NONE,
FILTERS_RESAMPLE);
rdpq_init();
joypad_init();
rdpq_text_register_font(1, rdpq_font_load_builtin(FONT_BUILTIN_DEBUG_VAR));
t3d_init((T3DInitParams){});
/* Audio — Opus wav64 is 48 kHz; audio_init must be >= that rate. */
audio_init(48000, 4);
mixer_init(16);
wav64_init_compression(3);
wav64_t *sfx_collect = wav64_load("rom:/collect.wav64", NULL);
wav64_t *sfx_ui = wav64_load("rom:/ui.wav64", NULL);
wav64_t *sfx_win = wav64_load("rom:/win.wav64", NULL);
xm64player_t music;
bool music_ok = true;
xm64player_open(&music, "rom:/music.xm64");
xm64player_set_loop(&music, true);
xm64player_set_vol(&music, 0.55f);
T3DViewport viewport = t3d_viewport_create_buffered(FB_COUNT);
T3DMat4FP *playerMat = malloc_uncached(sizeof(T3DMat4FP) * FB_COUNT);
T3DMat4FP *islandMat = malloc_uncached(sizeof(T3DMat4FP));
t3d_mat4fp_from_srt_euler(islandMat,
(float[3]){ 0.032f, 0.032f, 0.032f }, (float[3]){ 0, 0, 0 }, (float[3]){ 0, 0, 0 });
T3DModel *island = t3d_model_load("rom:/island.t3dm");
T3DModel *player = t3d_model_load("rom:/player_anim.t3dm");
T3DModel *shardM = t3d_model_load("rom:/starshard.t3dm");
T3DSkeleton skel = t3d_skeleton_create_buffered(player, FB_COUNT);
T3DSkeleton skelBlend = t3d_skeleton_clone(&skel, false);
T3DAnim animIdle = t3d_anim_create(player, "Snake_Idle");
T3DAnim animWalk = t3d_anim_create(player, "Snake_Walk");
t3d_anim_attach(&animIdle, &skel);
t3d_anim_attach(&animWalk, &skelBlend);
Shard shards[SHARD_N];
for (int i = 0; i < SHARD_N; i++) {
shards[i].pos = SHARD_SPAWNS[i];
shards[i].alive = true;
shards[i].pop = 0.f;
shards[i].mat = malloc_uncached(sizeof(T3DMat4FP));
}
GameState state = ST_TITLE;
fm_vec3_t pos = {{ 0, 0.15f, 0 }};
float yaw = 0.f, camYaw = 0.f;
fm_vec3_t eye = {{ 0, 8, 14 }}, look = {{ 0, 1, 0 }};
float last = ng_time_s();
int frame = 0, collected = 0;
float t = 0.f, playTime = 0.f;
float flash = 0.f;
float winBanner = 0.f;
float rumble_t = 0.f; /* seconds remaining of motor pulse */
char line[80];
uint8_t amb[4] = { 80, 95, 110, 255 };
uint8_t dirC[4] = { 255, 235, 210, 255 };
fm_vec3_t ldir = {{ 0.35f, 1.f, 0.3f }};
fm_vec3_norm(&ldir, &ldir);
for (;;) {
joypad_poll();
joypad_buttons_t pressed = joypad_get_buttons_pressed(JOYPAD_PORT_1);
joypad_inputs_t in = joypad_get_inputs(JOYPAD_PORT_1);
float dt = ng_time_s() - last;
last = ng_time_s();
if (dt < 0.f) {
dt = 0.f;
}
if (dt > 0.1f) {
dt = 0.1f;
}
frame = (frame + 1) % FB_COUNT;
t += dt;
if (flash > 0.f) {
flash -= dt * 3.f;
}
if (winBanner > 0.f) {
winBanner -= dt * 0.5f;
}
/* Rumble pulse: start on collect, stop when timer expires. */
if (rumble_t > 0.f) {
rumble_t -= dt;
if (rumble_t <= 0.f) {
rumble_t = 0.f;
joypad_set_rumble_active(JOYPAD_PORT_1, false);
}
}
/* State transitions */
if (state == ST_TITLE && pressed.start) {
if (sfx_ui) {
wav64_play(sfx_ui, CH_UI);
}
state = ST_PLAY;
collected = 0;
playTime = 0.f;
pos = (fm_vec3_t){{ 0, 0.15f, 0 }};
yaw = camYaw = 0.f;
for (int i = 0; i < SHARD_N; i++) {
shards[i].alive = true;
shards[i].pop = 0.f;
}
if (music_ok) {
xm64player_play(&music, CH_BGM);
}
} else if (state == ST_PLAY && pressed.start) {
if (sfx_ui) {
wav64_play(sfx_ui, CH_UI);
}
state = ST_PAUSE;
if (music_ok) {
xm64player_stop(&music);
}
} else if (state == ST_PAUSE && pressed.start) {
if (sfx_ui) {
wav64_play(sfx_ui, CH_UI);
}
state = ST_PLAY;
if (music_ok) {
xm64player_play(&music, CH_BGM);
}
} else if (state == ST_WIN && pressed.start) {
/* Stop lingering jingles before UI (avoids samplebuffer/Opus glitches). */
mixer_ch_stop(CH_WIN);
mixer_ch_stop(CH_COLLECT);
if (sfx_ui) {
wav64_play(sfx_ui, CH_UI);
}
state = ST_TITLE;
/* Music already stopped on win — leave it stopped on title. */
}
float blend = 0.f;
if (state == ST_PLAY) {
playTime += dt;
if (in.btn.c_left) {
camYaw -= 1.3f * dt;
}
if (in.btn.c_right) {
camYaw += 1.3f * dt;
}
float sx = (float)ng_dz(in.stick_x) / 80.f;
float sy = (float)ng_dz(in.stick_y) / 80.f;
float speed = sqrtf(sx * sx + sy * sy);
if (speed > 1.f) {
sx /= speed;
sy /= speed;
speed = 1.f;
}
/* Move relative to lagged camera (eye→player), not player yaw. */
float edx = eye.v[0] - pos.v[0];
float edz = eye.v[2] - pos.v[2];
float elen = sqrtf(edx * edx + edz * edz);
float c, s;
if (elen > 0.001f) {
float backNow = atan2f(edx, edz);
c = fm_cosf(backNow);
s = fm_sinf(backNow);
} else {
c = fm_cosf(camYaw);
s = fm_sinf(camYaw);
}
float mx = sx * c - sy * s;
float mz = -sx * s - sy * c;
if (speed > 0.15f) {
pos.v[0] += mx * 7.8f * dt;
pos.v[2] += mz * 7.8f * dt;
yaw = ng_lerp_angle(yaw, atan2f(mx, mz), 0.22f);
blend = speed;
}
pos.v[0] = ng_clamp(pos.v[0], -5.5f, 5.5f);
pos.v[2] = ng_clamp(pos.v[2], -5.5f, 5.5f);
for (int i = 0; i < SHARD_N; i++) {
if (!shards[i].alive) {
continue;
}
if (dist_xz(pos, shards[i].pos) < 1.15f) {
shards[i].alive = false;
collected++;
flash = 1.f;
/* Juice: short rumble if the accessory supports it. */
if (joypad_get_rumble_supported(JOYPAD_PORT_1)) {
joypad_set_rumble_active(JOYPAD_PORT_1, true);
rumble_t = 0.18f;
}
if (sfx_collect) {
wav64_play(sfx_collect, CH_COLLECT);
}
if (collected >= SHARD_N) {
state = ST_WIN;
winBanner = 1.f;
if (music_ok) {
xm64player_stop(&music);
}
/* Own channel — do not reuse stereo collect pair. */
if (sfx_win) {
wav64_play(sfx_win, CH_WIN);
}
}
}
}
}
float animDt = (state == ST_PAUSE) ? 0.f : dt;
t3d_anim_update(&animIdle, animDt);
t3d_anim_set_speed(&animWalk, 0.2f + blend);
t3d_anim_update(&animWalk, animDt);
t3d_skeleton_blend(&skel, &skel, &skelBlend, blend);
t3d_skeleton_update(&skel);
float back = camYaw; /* camera sits on camYaw, not player yaw */
fm_vec3_t eyeWant = {{
pos.v[0] + fm_sinf(back) * 12.f, pos.v[1] + 6.5f,
pos.v[2] + fm_cosf(back) * 12.f,
}};
fm_vec3_t lookWant = {{ pos.v[0], pos.v[1] + 1.35f, pos.v[2] }};
if (state == ST_TITLE) {
eyeWant = (fm_vec3_t){{ fm_sinf(t * 0.35f) * 15.f, 10.f, fm_cosf(t * 0.35f) * 15.f }};
lookWant = (fm_vec3_t){{ 0, 0.4f, 0 }};
}
float lag = ng_clamp(10.f * dt, 0.08f, 0.4f);
for (int k = 0; k < 3; k++) {
eye.v[k] = ng_lerp(eye.v[k], eyeWant.v[k], lag);
look.v[k] = ng_lerp(look.v[k], lookWant.v[k], lag);
}
t3d_viewport_set_projection(&viewport, T3D_DEG_TO_RAD(58.f), 1.f, 200.f);
t3d_viewport_look_at(&viewport, &eye, &look, &(fm_vec3_t){{ 0, 1, 0 }});
/*
* Scales (course t3dm integer units → ~12u island):
* island ~±384 → 0.032
* snake ~height 80 → 0.02 (~1.6u tall)
* starshard ~height 100 → ~0.02 (~2u crystal)
*/
t3d_mat4fp_from_srt_euler(&playerMat[frame],
(float[3]){ 0.02f, 0.02f, 0.02f },
(float[3]){ 0.f, -yaw, 0.f },
(float[3]){ pos.v[0], pos.v[1], pos.v[2] });
for (int i = 0; i < SHARD_N; i++) {
if (!shards[i].alive) {
continue;
}
float bob = fm_sinf(t * 3.2f + i * 0.7f) * 0.14f;
float spin = t * 1.5f + i;
float sc = 0.02f * (1.0f + 0.08f * fm_sinf(t * 5.f + i));
t3d_mat4fp_from_srt_euler(shards[i].mat,
(float[3]){ sc, sc, sc },
(float[3]){ 0.2f, spin, 0.1f },
(float[3]){ shards[i].pos.v[0], shards[i].pos.v[1] + bob, shards[i].pos.v[2] });
}
/* ----- Draw 3D ----- */
rdpq_attach(display_get(), display_get_zbuf());
t3d_frame_start();
t3d_viewport_attach(&viewport);
color_t clear = RGBA32(25, 55, 100, 0xFF);
if (flash > 0.f) {
uint8_t add = (uint8_t)(flash * 80.f);
clear = RGBA32(25 + add, 55 + add / 2, 100, 0xFF);
}
t3d_screen_clear_color(clear);
t3d_screen_clear_depth();
t3d_light_set_ambient(amb);
t3d_light_set_directional(0, dirC, &ldir);
t3d_light_set_count(1);
t3d_matrix_push(islandMat);
if (island) {
t3d_model_draw(island);
}
t3d_matrix_pop(1);
for (int i = 0; i < SHARD_N; i++) {
if (!shards[i].alive) {
continue;
}
t3d_matrix_push(shards[i].mat);
if (shardM) {
t3d_model_draw(shardM);
}
t3d_matrix_pop(1);
}
/* Always draw player (title shows idle on the island too). */
t3d_skeleton_use(&skel);
t3d_matrix_push(&playerMat[frame]);
t3d_model_draw_skinned(player, &skel);
t3d_matrix_pop(1);
/* ----- HUD (2D over 3D) ----- */
rdpq_set_mode_standard();
if (state == ST_TITLE) {
rdpq_text_print(NULL, 1, 70, 70, "L34 JUICE");
rdpq_text_print(NULL, 1, 55, 100, "Collect 3 — feel the juice");
rdpq_text_print(NULL, 1, 75, 130, "Press START");
rdpq_text_print(NULL, 1, 40, 200, "Stick move C-L/R cam START pause");
} else if (state == ST_PAUSE) {
rdpq_text_print(NULL, 1, 120, 90, "PAUSED");
rdpq_text_print(NULL, 1, 55, 120, "START resume");
snprintf(line, sizeof(line), "Shards %d/%d time %.1fs", collected, SHARD_N, playTime);
rdpq_text_print(NULL, 1, 40, 150, line);
} else if (state == ST_WIN) {
rdpq_text_print(NULL, 1, 100, 70, "YOU WIN!");
snprintf(line, sizeof(line), "Time: %.1f seconds", playTime);
rdpq_text_print(NULL, 1, 70, 100, line);
const char *rank = "Bronze";
if (playTime < 45.f) {
rank = "Gold";
} else if (playTime < 90.f) {
rank = "Silver";
}
snprintf(line, sizeof(line), "Rank: %s", rank);
rdpq_text_print(NULL, 1, 100, 120, line);
rdpq_text_print(NULL, 1, 55, 160, "START — title");
rdpq_text_print(NULL, 1, 30, 200, "N64 Educator capstone");
} else {
/* PLAY HUD */
snprintf(line, sizeof(line), "Shards %d/%d", collected, SHARD_N);
rdpq_text_print(NULL, 1, 10, 12, line);
snprintf(line, sizeof(line), "Time %.1f", playTime);
rdpq_text_print(NULL, 1, 200, 12, line);
rdpq_text_print(NULL, 1, 10, 220, "START pause");
}
rdpq_detach_show();
mixer_try_play();
}
}Next
L38 — Save & high scores · then L39 · L35 Capstone.