505 lines
13 KiB
Plaintext
505 lines
13 KiB
Plaintext
var os = use('os');
|
|
var io = use('io');
|
|
var transform = use('transform');
|
|
var rasterize = use('rasterize');
|
|
var time = use('time')
|
|
var tilemap = use('tilemap')
|
|
var json = use('json')
|
|
|
|
// Frame timing variables
|
|
var framerate = 60
|
|
|
|
var game = args[0]
|
|
|
|
io.writepath(game)
|
|
|
|
var video
|
|
|
|
//var cnf = use('accio/config')
|
|
|
|
$_.start(e => {
|
|
if (e.type != 'greet') return
|
|
video = e.actor
|
|
graphics = use('graphics', video)
|
|
send(video, {kind:"window", op:"makeRenderer"}, e => {
|
|
$_.start(e => {
|
|
if (gameactor) return
|
|
gameactor = e.actor
|
|
$_.couple(gameactor)
|
|
start_pipeline()
|
|
}, args[0], $_, video)
|
|
})
|
|
}, 'prosperon/sdl_video', {})
|
|
|
|
var geometry = use('geometry')
|
|
var dmon = use('dmon')
|
|
var res = use('resources')
|
|
|
|
// Start watching for file changes
|
|
dmon.watch('.')
|
|
|
|
var camera = {}
|
|
|
|
function updateCameraMatrix(cam) {
|
|
def win_w = logical.width
|
|
def win_h = logical.height
|
|
def view_w = (cam.size?.[0] ?? win_w) / cam.zoom
|
|
def view_h = (cam.size?.[1] ?? win_h) / cam.zoom
|
|
|
|
def ox = cam.pos[0] - view_w * (cam.anchor?.[0] ?? 0)
|
|
def oy = cam.pos[1] - view_h * (cam.anchor?.[1] ?? 0)
|
|
|
|
def vx = (cam.viewport?.x ?? 0) * win_w
|
|
def vy = (cam.viewport?.y ?? 0) * win_h
|
|
def vw = (cam.viewport?.width ?? 1) * win_w
|
|
def vh = (cam.viewport?.height ?? 1) * win_h
|
|
|
|
def sx = vw / view_w
|
|
def sy = vh / view_h // flip-Y later
|
|
|
|
/* affine matrix that SDL wants (Y going down) */
|
|
cam.a = sx
|
|
cam.c = vx - sx * ox
|
|
cam.e = -sy // <-- minus = flip Y
|
|
cam.f = vy + vh + sy * oy
|
|
|
|
/* convenience inverses */
|
|
cam.ia = 1 / cam.a
|
|
cam.ic = -cam.c / cam.a
|
|
cam.ie = 1 / cam.e
|
|
cam.if = -cam.f / cam.e
|
|
|
|
camera = cam
|
|
}
|
|
|
|
//---- forward transform ----
|
|
function worldToScreenPoint([x,y], camera) {
|
|
return {
|
|
x: camera.a * x + camera.c,
|
|
y: camera.e * y + camera.f
|
|
};
|
|
}
|
|
|
|
//---- inverse transform ----
|
|
function screenToWorldPoint(pos, camera) {
|
|
return {
|
|
x: camera.ia * pos[0] + camera.ic,
|
|
y: camera.ie * pos[1] + camera.if
|
|
};
|
|
}
|
|
|
|
//---- rectangle (two corner) ----
|
|
function worldToScreenRect({x,y,width,height}, camera) {
|
|
// map bottom-left and top-right
|
|
def x1 = camera.a * x + camera.c;
|
|
def y1 = camera.e * y + camera.f;
|
|
def x2 = camera.a * (x + width) + camera.c;
|
|
def y2 = camera.e * (y + height) + camera.f;
|
|
|
|
return {
|
|
x:Math.min(x1,x2),
|
|
y:Math.min(y1,y2),
|
|
width:Math.abs(x2-x1),
|
|
height:Math.abs(y2-y1)
|
|
}
|
|
}
|
|
|
|
var graphics
|
|
|
|
var gameactor
|
|
|
|
var images = {}
|
|
|
|
var renderer_commands = []
|
|
|
|
var win_size = {width:500,height:500}
|
|
var logical = {width:500,height:500}
|
|
|
|
// Convert high-level draw commands to low-level renderer commands
|
|
function translate_draw_commands(commands) {
|
|
if (!graphics) return
|
|
|
|
renderer_commands.length = 0
|
|
|
|
commands.forEach(function(cmd) {
|
|
if (cmd.material && cmd.material.color) {
|
|
renderer_commands.push({
|
|
op: "set",
|
|
prop: "drawColor",
|
|
value: cmd.material.color
|
|
})
|
|
}
|
|
|
|
switch(cmd.cmd) {
|
|
case "camera":
|
|
updateCameraMatrix(cmd.camera, win_size.width, win_size.height)
|
|
break
|
|
|
|
case "draw_rect":
|
|
cmd.rect = worldToScreenRect(cmd.rect, camera)
|
|
// Handle rectangles with optional rounding and thickness
|
|
if (cmd.opt && cmd.opt.radius && cmd.opt.radius > 0) {
|
|
// Rounded rectangle
|
|
var thickness = (cmd.opt.thickness == 0) ? 0 : (cmd.opt.thickness || 1)
|
|
var raster_result = rasterize.round_rect(cmd.rect, cmd.opt.radius, thickness)
|
|
|
|
if (raster_result.type == 'rect') {
|
|
renderer_commands.push({
|
|
op: "fillRect",
|
|
data: {rect: raster_result.data}
|
|
})
|
|
} else if (raster_result.type == 'rects') {
|
|
raster_result.data.forEach(function(rect) {
|
|
renderer_commands.push({
|
|
op: "fillRect",
|
|
data: {rect: rect}
|
|
})
|
|
})
|
|
}
|
|
} else if (cmd.opt && cmd.opt.thickness && cmd.opt.thickness > 0) {
|
|
// Outlined rectangle
|
|
var raster_result = rasterize.outline_rect(cmd.rect, cmd.opt.thickness)
|
|
|
|
if (raster_result.type == 'rect') {
|
|
renderer_commands.push({
|
|
op: "fillRect",
|
|
data: {rect: raster_result.data}
|
|
})
|
|
} else if (raster_result.type == 'rects') {
|
|
renderer_commands.push({
|
|
op: "rects",
|
|
data: {rects: raster_result.data}
|
|
})
|
|
}
|
|
} else {
|
|
renderer_commands.push({
|
|
op: "fillRect",
|
|
data: {rect: cmd.rect}
|
|
})
|
|
}
|
|
break
|
|
|
|
case "draw_circle":
|
|
case "draw_ellipse":
|
|
cmd.pos = worldToScreenPoint(cmd.pos, camera)
|
|
// Rasterize ellipse to points or rects
|
|
var radii = cmd.radii || [cmd.radius, cmd.radius]
|
|
var raster_result = rasterize.ellipse(cmd.pos, radii, cmd.opt || {})
|
|
|
|
if (raster_result.type == 'points') {
|
|
renderer_commands.push({
|
|
op: "point",
|
|
data: {points: raster_result.data}
|
|
})
|
|
} else if (raster_result.type == 'rects') {
|
|
// Use 'rects' operation for multiple rectangles
|
|
renderer_commands.push({
|
|
op: "rects",
|
|
data: {rects: raster_result.data}
|
|
})
|
|
}
|
|
break
|
|
|
|
case "draw_line":
|
|
renderer_commands.push({
|
|
op: "line",
|
|
data: {points: cmd.points.map(p => {
|
|
var pt = worldToScreenPoint(p, camera)
|
|
return [pt.x, pt.y]
|
|
})}
|
|
})
|
|
break
|
|
|
|
case "draw_point":
|
|
cmd.pos = worldToScreenPoint(cmd.pos, camera)
|
|
renderer_commands.push({
|
|
op: "point",
|
|
data: {points: [cmd.pos]}
|
|
})
|
|
break
|
|
|
|
case "draw_image":
|
|
var img = graphics.texture(cmd.image)
|
|
var gpu = img.gpu
|
|
if (!gpu) break
|
|
|
|
cmd.rect.width ??= img.width
|
|
cmd.rect.height ??= img.height
|
|
cmd.rect = worldToScreenRect(cmd.rect, camera)
|
|
|
|
renderer_commands.push({
|
|
op: "texture",
|
|
data: {
|
|
texture_id: gpu.id,
|
|
dst: cmd.rect,
|
|
src: img.rect
|
|
}
|
|
})
|
|
|
|
break
|
|
|
|
case "draw_text":
|
|
if (!cmd.text) break
|
|
if (!cmd.pos) break
|
|
var rect = worldToScreenRect({x:cmd.pos.x, y:cmd.pos.y, width:8, height:8}, camera)
|
|
var pos = {x: rect.x, y: rect.y}
|
|
renderer_commands.push({
|
|
op: "debugText",
|
|
data: {
|
|
pos,
|
|
text: cmd.text
|
|
}
|
|
})
|
|
break
|
|
|
|
case "draw_slice9":
|
|
var img = graphics.texture(cmd.image)
|
|
var gpu = img.gpu
|
|
if (!gpu) break
|
|
|
|
cmd.rect = worldToScreenRect(cmd.rect, camera)
|
|
|
|
renderer_commands.push({
|
|
op: "texture9Grid",
|
|
data: {
|
|
texture_id: gpu.id,
|
|
src: img.rect,
|
|
leftWidth: cmd.slice,
|
|
rightWidth: cmd.slice,
|
|
topHeight: cmd.slice,
|
|
bottomHeight: cmd.slice,
|
|
scale: 1.0,
|
|
dst: cmd.rect
|
|
}
|
|
})
|
|
break
|
|
|
|
case "tilemap":
|
|
// Group tiles by texture to batch draw calls
|
|
var textureGroups = {}
|
|
var tilePositions = []
|
|
|
|
// Collect all tiles and their positions
|
|
tilemap.for(cmd.tilemap, (tile, {x,y}) => {
|
|
if (tile) {
|
|
tilePositions.push({tile, x, y})
|
|
}
|
|
})
|
|
|
|
// Group tiles by texture
|
|
tilePositions.forEach(({tile, x, y}) => {
|
|
var img = graphics.texture(tile)
|
|
if (img && img.gpu) {
|
|
var texId = img.gpu.id
|
|
if (!textureGroups[texId]) {
|
|
textureGroups[texId] = {
|
|
texture: img,
|
|
tiles: []
|
|
}
|
|
}
|
|
textureGroups[texId].tiles.push({x, y, img})
|
|
}
|
|
})
|
|
|
|
// Generate draw commands for each texture group
|
|
Object.keys(textureGroups).forEach(texId => {
|
|
var group = textureGroups[texId]
|
|
var tiles = group.tiles
|
|
|
|
// Create a temporary tilemap with only tiles from this texture
|
|
// Apply tilemap position to the offset to shift the world coordinates
|
|
var tempMap = {
|
|
tiles: [],
|
|
offset_x: cmd.tilemap.offset_x + (cmd.tilemap.pos.x / cmd.tilemap.size_x),
|
|
offset_y: cmd.tilemap.offset_y + (cmd.tilemap.pos.y / cmd.tilemap.size_y),
|
|
size_x: cmd.tilemap.size_x,
|
|
size_y: cmd.tilemap.size_y
|
|
}
|
|
|
|
// Build sparse array for this texture's tiles
|
|
tiles.forEach(({x, y, img}) => {
|
|
var arrayX = x - cmd.tilemap.offset_x
|
|
var arrayY = y - cmd.tilemap.offset_y
|
|
if (!tempMap.tiles[arrayX]) tempMap.tiles[arrayX] = []
|
|
tempMap.tiles[arrayX][arrayY] = img
|
|
})
|
|
|
|
// Generate geometry for this texture group
|
|
var geom = geometry.tilemap_to_data(tempMap)
|
|
geom.texture_id = parseInt(texId)
|
|
|
|
renderer_commands.push({
|
|
op: "geometry_raw",
|
|
data: geom
|
|
})
|
|
})
|
|
break
|
|
}
|
|
})
|
|
|
|
return renderer_commands
|
|
}
|
|
|
|
var parseq = use('parseq', $_.delay)
|
|
|
|
// Wrap `send(actor,msg,cb)` into a parseq “requestor”
|
|
// • on success: cb(data) → value=data, reason=null
|
|
// • on failure: cb(null,err)
|
|
function rpc_req(actor, msg) {
|
|
return (cb, _) => {
|
|
send(actor, msg, data => {
|
|
if (data.error)
|
|
cb(null, data)
|
|
else
|
|
cb(data)
|
|
})
|
|
}
|
|
}
|
|
|
|
var pending_draw = null
|
|
var pending_next = null
|
|
var last_time = time.number()
|
|
|
|
var input = use('input')
|
|
|
|
var input_state = {
|
|
poll: 1/framerate
|
|
}
|
|
|
|
// 1) input runs completely independently
|
|
function poll_input() {
|
|
send(video, {kind:'input', op:'get'}, evs => {
|
|
for (var ev of evs) {
|
|
if (ev.type == 'window_pixel_size_changed') {
|
|
win_size.width = ev.width
|
|
win_size.height = ev.height
|
|
}
|
|
|
|
if (ev.type == 'quit')
|
|
$_.stop()
|
|
|
|
if (ev.type.includes('key')) {
|
|
if (ev.key)
|
|
ev.key = input.keyname(ev.key)
|
|
}
|
|
|
|
if (ev.type.startsWith('mouse_'))
|
|
ev.pos.y = -ev.pos.y + logical.height
|
|
}
|
|
|
|
send(gameactor, evs)
|
|
})
|
|
$_.delay(poll_input, input_state.poll)
|
|
}
|
|
|
|
// 2) helper to build & send a batch, then call done()
|
|
function create_batch(draw_cmds, done) {
|
|
def batch = [
|
|
{op:'set', prop:'drawColor', value:{r:0.1,g:0.1,b:0.15,a:1}},
|
|
{op:'clear'}
|
|
]
|
|
if (draw_cmds && draw_cmds.length)
|
|
batch.push(...translate_draw_commands(draw_cmds))
|
|
|
|
batch.push(
|
|
{op:'set', prop:'drawColor', value:{r:1,g:1,b:1,a:1}},
|
|
{op:'imgui_render'},
|
|
{op:'present'}
|
|
)
|
|
|
|
send(video, {kind:'renderer', op:'batch', data:batch}, done)
|
|
}
|
|
|
|
// File watching loop
|
|
function poll_file_changes() {
|
|
dmon.poll(e => {
|
|
if (e.action == 'modify' || e.action == 'create') {
|
|
// Check if it's an image file
|
|
var ext = e.file.split('.').pop().toLowerCase()
|
|
var imageExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tga', 'webp', 'qoi', 'ase', 'aseprite']
|
|
|
|
if (imageExts.includes(ext)) {
|
|
// Try to find the full path for this image
|
|
var possiblePaths = [
|
|
e.file,
|
|
e.root + e.file,
|
|
res.find_image(e.file.split('/').pop().split('.')[0])
|
|
].filter(p => p)
|
|
|
|
for (var path of possiblePaths) {
|
|
graphics.tex_hotreload(path)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
// Schedule next poll in 0.5 seconds
|
|
$_.delay(poll_file_changes, 0.5)
|
|
}
|
|
|
|
// 3) kick off the very first update→draw
|
|
function start_pipeline() {
|
|
poll_input()
|
|
poll_file_changes() // Start file watching loop
|
|
send(gameactor, {kind:'update', dt:0}, () => {
|
|
send(gameactor, {kind:'draw'}, cmds => {
|
|
pending_draw = cmds
|
|
render_step()
|
|
})
|
|
})
|
|
}
|
|
|
|
function render_step() {
|
|
// a) Calculate actual dt since last frame
|
|
def now = time.number()
|
|
def dt = now - last_time
|
|
last_time = now
|
|
|
|
// b) Send update with actual dt, then wait for draw response
|
|
send(gameactor, {kind:'update', dt}, () => {
|
|
send(gameactor, {kind:'draw'}, cmds => {
|
|
// Only render after receiving draw commands
|
|
pending_draw = cmds
|
|
|
|
// c) render the current frame
|
|
create_batch(pending_draw, _ => { // time to render
|
|
def frame_end = time.number()
|
|
def wait_time = Math.max(0, (frame_end - now) - 1/framerate)
|
|
|
|
// e) Schedule next frame
|
|
$_.delay(render_step, wait_time)
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
$_.receiver(e => {
|
|
switch(e.op) {
|
|
case 'resolution':
|
|
log.console(json.encode(e))
|
|
send(video, {
|
|
kind:'renderer',
|
|
op:'set',
|
|
prop:'logicalPresentation',
|
|
value: {...e}
|
|
})
|
|
logical.width = e.width
|
|
logical.height = e.height
|
|
break
|
|
case 'window_size':
|
|
send(video, {
|
|
kind:'window',
|
|
op:'set',
|
|
data: {property: 'size', value: [e.width, e.height]}
|
|
})
|
|
break
|
|
case 'framerate':
|
|
// Allow setting target framerate dynamically
|
|
if (e.fps && e.fps > 0) {
|
|
framerate = e.fps
|
|
input_state.poll = 1/framerate
|
|
}
|
|
break
|
|
}
|
|
})
|