-
Notifications
You must be signed in to change notification settings - Fork 0
Advanced Tutorial
This tutorial covers advanced techniques derived from real-world scenecontrol scripts. It assumes you have read the basic tutorial and are comfortable with channels, controllers, and scenecontrol types. (maybe even Unity editor)
When you need to debug scene, it's encouraged to just download Unity editor to debug scene.
- Utility Libraries
- Replacing the Default Track
- Sprite Sheet Animation
- Advanced Triggers
- Dynamic Text
- Object Pooling
- Tagging Note Groups
- Screen Adaptation
- Extra
Real scripts quickly become unwieldy without helpers. A utility module is almost always the first thing you write.
Creating Channel.constant(1) repeatedly with the autocast wastes memory. Cache them:
local constants = {}
local function val(v)
if type(v) == "number" then
if constants[v] == nil then
constants[v] = Channel.constant(v)
end
return constants[v]
end
return v
endNow val(5) always returns the same ConstantChannel instance for 5.
local function activeThrough(channel, t1, t2)
if channel == nil then
channel = Channel.keyframe()
.setDefaultEasing("inconst")
.addKey(-61616, 0)
end
return channel.addKey(t1, 1).addKey(t2, 0)
end
local function inactiveThrough(channel, t1, t2)
if channel == nil then
channel = Channel.keyframe()
.setDefaultEasing("inconst")
.addKey(-61616, 1)
end
return channel.addKey(t1, 0).addKey(t2, 1)
endUsage:
local mySprite = Scene.createSprite("test.png")
mySprite.active = activeThrough(nil, 10000, 20000)This can be done with other controller properties as well, using color as exmple here.
local function setColor(controller, r, g, b, a)
controller.colorR = r
controller.colorG = g
controller.colorB = b
if a ~= nil then controller.colorA = a end
end
The default track can be hidden and replaced with custom sprites.
-- Hide the default track
Scene.track.active = Channel.constant(0)
-- Create a single-lane custom track body
local track = Scene.createSprite(
"NewTrack.png",
"default",
"background",
xy(0.5, 0),
"repeat"
)
track.layer = "Track"
track.sort = 0
track.rotationX = -90
track.scaleX = -1.7896 * 950/4/1024
track.scaleY = 153.5/2.55
-- Make the texture repeat along the track
track.textureScaleX = 950/4/1024
track.textureScaleY = track.scaleY
-- Scroll the texture like the default track
local floorPosition = Context.floorPosition(0) / 15000 / 100
track.textureOffsetY = floorPositionInstead of creating divide lines and critical lines from scratch, copy the internal ones:
local crit = Scene.track.criticalLine1.copy()
crit.active = Channel.constant(1)
crit.setParent(track)
crit.translationX = 0
crit.translationY = 0.88275
crit.scaleY = -1/16
crit.scaleX = 1024For independent lane movement, parent each lane to its own canvas:
local trackParents = {}
for i = 0, 5 do
trackParents[i] = Scene.createCanvas(true)
trackParents[i].setParent(Scene.worldCanvas)
-- Now animate each lane independently
trackParents[i].translationX = Channel.keyframe().addKey(0, originalX[i])
endUse textureOffsetX/textureScaleX to display different frames of a sprite sheet.
local loading = Scene.createSprite("catexplosion.png", "default", "overlay")
loading.setParent(Scene.cameraCanvas)
-- The sprite sheet has 12 frames horizontally
loading.textureScaleX = Channel.constant(1/12)
loading.textureOffsetX = Channel.keyframe().setDefaultEasing("inconst")
local frameCount = 0
local totalFrames = 12
for t = -1999, -120, 18 do
loading.textureOffsetX.addKey(t, frameCount)
frameCount = frameCount + 1
if frameCount >= totalFrames then frameCount = 0 end
endlocal bpm = Context.bpm(0).valueAt(0)
local beatDuration = 60000 / bpm
local player = Scene.createSprite("taiko/playerchar.png")
player.textureScaleY = 1/3 -- 3 rows
player.textureScaleX = 1/4 -- 4 columns
-- Cycle through columns on the beat
local frame = Channel.saw("l", beatDuration * 2, 0, 1, 0)
local bpm = Channel.saw("l", Context.beatLength(0)*2/Channel.condition(playerchar.textureOffsetY, 0, playerchar.textureOffsetY, 2, playerchar.textureOffsetY), 0, 1, 0)
local frameOffset = Channel.condition(
0.5,
bpm,
Channel.condition(
0.1, bpm, 1, 0, 0
),
1,
Channel.condition(
0.6, bpm, 2, 3, 3
)
)
playerchar.textureOffsetX = frameOffset
Triggers react to gameplay events. Combine them with TriggerChannel to drive visual effects.
--yanked from c0pyf0x freeform lane part but unused
local perfectTrigger = TriggerChannel.loop(
Trigger.judgement().onPerfect().dispatch(1, 500, "l")
)
local goodTrigger = TriggerChannel.loop(
Trigger.judgement().onGood().dispatch(1, 500, "l")
)
local missTrigger = TriggerChannel.loop(
Trigger.judgement().onMiss().dispatch(1, 500, "l")
)
local perfect = Scene.createSprite("judgetext/perfect.png", "default", "overlay")
perfect.setParent(Scene.combo)
perfect.scaleX = 65
perfect.scaleY = 65
perfect.translationY = 65 + perfectTrigger * 20
perfect.colorA = TriggerChannel.stack(
Trigger.judgement().onPerfect().dispatch(-255, 200, "l"),
Trigger.observe(init).goAbove(0.5).dispatch(-255, 1, "l")
).setBaseValue(255)--yanked from c0pyf0x taiko part
local onMiss = Trigger.judgement().onMiss().dispatch(1, 0, "l") -- dispatch 1
local onHit = Trigger.judgement().onPerfect().onGood().dispatch(0, 0, "l") -- dispatch 0
-- 1 when missed, 0 when hit
local failImmediate = TriggerChannel.setTo(onMiss, onHit).setBaseValue(1)
-- Swap sprites based on fail state
local normalScroller = Scene.createSprite("scroller.png")
normalScroller.colorA = alpha * (1 - failImmediate)
local failScroller = Scene.createSprite("scroller_fail.png")
failScroller.colorA = alpha * failImmediate| Method | Behavior |
|---|---|
TriggerChannel.loop(trigger, fallback) |
Jumps to dispatched value on trigger, returns to fallback |
TriggerChannel.setTo(trigger, trigger2, ...) |
Keeps the last triggered value |
TriggerChannel.accumulate(trigger, fallback) |
Adds dispatched value on each trigger |
TriggerChannel.stack(triggers...) |
Combines multiple triggers |
Note:
TriggerChannel.setBaseValue(value)sets the initial value before any trigger fires.
local comboText = Scene.createText("Saira Regular", 42, 0, "uppercenter", "overlay")
local empty = TextChannel.create().addKey(0, "")
local comboNumber = TextChannel.fromValue(Context.currentCombo, 10, 0)
local comboSuffix = TextChannel.constant("<size=20>\nCOMBO</size>")
-- Build the full text
comboText.text = empty.concat(empty, comboNumber)
comboText.text = comboText.text.concat(comboText.text, comboSuffix)
TextChannel.concat(a, b)concatenates two text channels.
Creating sprites during gameplay scenecontrol events can be slow. Pool objects and reuse them:
---@class rech.Pooling
---@field create_function fun(): Controller
---@field controllers Controller[]
local Pooling = {}
Pooling.__index = Pooling
---@param fn fun(): Controller
local function create(fn)
local controller = fn()
controller.active = Channel.keyframe().setDefaultEasing("cnsti").addKey(-999999, 0)
return controller
end
---Creates new pool with supplied function that creates the controller
---@param fn fun(): Controller
function Pooling.new(fn)
local self = setmetatable({}, Pooling)
self.create_function = fn
self.controllers = {}
self.active_ranges = {}
return self
end
---Get an instance that is active from t1 to t2
---@param t1 any
---@param t2 any
function Pooling:get_instance(t1, t2)
for i,controller in pairs(self.controllers) do
local busy = false
for _,range in ipairs(self.active_ranges[i]) do
if t1 <= range.end_timing and t2 >= range.start_timing then
busy = true
break
end
end
if not busy then
controller.active.addKey(t1, 1).addKey(t2, 0)
table.insert(self.active_ranges[i], {start_timing = t1, end_timing = t2})
return controller
end
end
local controller = create(self.create_function)
controller.active.addKey(t1, 1).addKey(t2, 0)
self.controllers[#self.controllers+1] = controller
self.active_ranges[#self.active_ranges+1] = {{start_timing = t1, end_timing = t2}}
return controller
end
return PoolingUsage for particle bursts:
local particlePool = Pooling.new(function()
local p = Scene.createSprite("particle.png", "fastadd", "overlay")
p.setParent(Scene.cameraCanvas)
p.layer = StringChannel.constant("Effect")
-- initialize the channel you will modify HERE!!
p.translationX = Channel.keyframe()
p.translationY = Channel.keyframe()
p.colorA = Channel.keyframe()
return p
end)
addScenecontrol("burst", 0, function(cmd)
local t = cmd.timing
local p = particlePool:get_instance(t)
p.translationX.addKey(t, 0).addKey(t + 500, math.random(-100, 100))
p.translationY.addKey(t, 0).addKey(t + 500, math.random(-100, 100))
p.colorA.addKey(t, 255).addKey(t + 500, 0)
end)Instead of hardcoding timing group numbers, use a tagging system:
local Tagging = {}
local mappings = {}
function Tagging.addTag(tagName, func)
mappings[tagName] = func
end
addScenecontrol("tag", {"name"}, function(cmd)
local name = cmd.args[1]
if mappings[name] ~= nil then
local tg = Scene.getNoteGroup(cmd.timingGroup)
mappings[name](tg)
end
end)
return TaggingUsage:
local tagging = require("tagging")
tagging.addTag("spin", function(tg)
tg.rotationIndividualZ = Channel.keyframe()
.addKey(0, 0, "si")
.addKey(500, -720, "cnsti")
.addKey(1000, 0)
end)Then in your .aff file, add this to one or more timing group you wish to take effect:
scenecontrol(0,tag,"spin");
-- How far the screen deviates from 16:9
local p = (Context.screenHeight / Context.screenWidth * 16 - 9) / 3
-- Clamp to 0..1
local pClamped = Channel.clamp(p, 0, 1)By reparenting, you can compose a 3D background (maybe even skybox) with sprites.
-- yanked from copyfox
local worldParent = Scene.createCanvas(true)
worldParent.setParent(Scene.worldCanvas)
worldParent.scaleX = -1 -- Flip to face camera
worldParent.scaleY = 1
worldParent.scaleZ = -1
worldParent.translationZ = -30
-- Everything in the background is parented here
local path = Scene.createSprite("bg/path.jpg", "default", "background", xy(0.5, 0.5))
path.setParent(worldParent)
path.rotationX = 90
path.scaleX = 3
path.scaleY = 20000 / 1024
path.textureScaleY = 10
path.translationZ = -90
-- Texture scrolling
path.textureOffsetY = Channel.saw("l", 60000/168*8 - 300, 0, -1, 0)local newController = Scene.createSprite("black.png", "default", "overlay")
newController.copyAllChannelsFrom(refController)This copies transform, color, texture, and active channels from refController to newController.
Global
- Global Functions
- Scene
- Channel
- StringChannel
- TextChannel
- Trigger
- TriggerChannel
- Context
- Event
- Convert
Channels
Controllers
- Controller
- CanvasController
- ImageController
- SpriteController
- TextController
- CameraController
- TrackController
- NoteGroupController
Internal Controllers
Data types