From 4f97a3f18efd15a2c7c263c681d3f0607cdfbbff Mon Sep 17 00:00:00 2001 From: John Alanbrook Date: Sat, 29 Nov 2025 17:24:27 -0600 Subject: [PATCH] add graphics back --- graphics.cm | 401 +++++++++++++++++++++++++++++++++++++++++++++++++++ mersenne.c | 148 +++++++++++++++++++ qjs_math.c | 25 +++- resources.cm | 166 +++++++++++++++++++++ 4 files changed, 737 insertions(+), 3 deletions(-) create mode 100644 graphics.cm create mode 100644 mersenne.c create mode 100644 resources.cm diff --git a/graphics.cm b/graphics.cm new file mode 100644 index 00000000..4ff7a118 --- /dev/null +++ b/graphics.cm @@ -0,0 +1,401 @@ +var graphics = this + +var io = use('cellfs') +var time = use('time') +var res = use('resources') +var json = use('json') +var os = use('os') +var staef = use('staef') +var qoi = use('qoi') + +var LASTUSE = Symbol() +var LOADING = Symbol() + +var cache = {} + +// cpu is the surface +graphics.Image = function(surfaceData) { + this.cpu = surfaceData || null; + this.texture = 0; + this.surface = this.cpu; + this.width = surfaceData?.width || 0; + this.height = surfaceData?.height || 0; + this.rect = {x:0, y:0, width:this.width, height:this.height}; + this[LOADING] = false; + this[LASTUSE] = time.number(); +} + +graphics.Image.prototype.unload_cpu = function() { + this.cpu = null; + this.surface = null; +} + +function calc_image_size(img) { + if (!img.rect) return + if (img.texture) { + return [img.texture.width * img.rect.width, img.texture.height * img.rect.height] + } else if (img.cpu) { + return [img.cpu.width * img.rect.width, img.cpu.height * img.rect.height] + } + return [0, 0] +} + +function decorate_rect_px(img) { + // default UV rect is the whole image if none supplied + img.rect ??= {x:0, y:0, width:1, height:1} // [u0,v0,uw,vh] in 0-1 + + var width = 0, height = 0; + if (img.texture) { + width = img.texture.width; + height = img.texture.height; + } else if (img.cpu) { + width = img.cpu.width; + height = img.cpu.height; + } else { + return; + } + + // store pixel-space version: [x, y, w, h] in texels + img.rect_px = { + x:Math.round(img.rect.x * width), + y:Math.round(img.rect.y * height), + width:Math.round(img.rect.width * width), + height:Math.round(img.rect.height * height) + } +} + +function make_handle(obj) +{ + var img = new graphics.Image(obj); + decorate_rect_px(img); + return img; +} + +function wrapSurface(surf, maybeRect){ + def h = make_handle(surf); + if(maybeRect) h.rect = maybeRect; /* honour frame sub-rect */ + return h; +} +function wrapFrames(arr){ /* [{surface,time,rect}, …] → [{image,time}] */ + return arr.map(f => { + // Handle both surface objects and objects with surface property + var surf = f.surface || f; + return { + image: wrapSurface(surf), + time: f.time || 0, + rect: f.rect /* keep for reference */ + } + }); +} +function makeAnim(frames, loop=true){ + return { frames, loop } +} + +function decode_image(bytes, ext) +{ + switch(ext) { + case 'gif': return graphics.make_gif(bytes) // returns array of surfaces + case 'ase': + case 'aseprite': return graphics.make_aseprite(bytes) + case 'qoi': return qoi.decode(bytes) // returns single surface + default: + // Try QOI first since it's fast to check + var qoi_result = qoi.decode(bytes) + if (qoi_result) return qoi_result + // Fall back to make_texture for other formats + return graphics.image_decode(bytes) // returns single surface + } +} + +function create_image(path){ + try{ + def bytes = io.slurpbytes(path); + + let raw = decode_image(bytes, path.ext()); + + /* ── Case A: single surface (from make_texture) ────────────── */ + if(raw && raw.width && raw.pixels && !Array.isArray(raw)) { + return new graphics.Image(raw) + } + + /* ── Case B: array of surfaces (from make_gif) ────────────── */ + if(Array.isArray(raw)) { + // Single frame GIF returns array with one surface + if(raw.length == 1 && !raw[0].time) { + return new graphics.Image(raw[0]) + } + // Multiple frames - create animation + return makeAnim(wrapFrames(raw), true); + } + + if(typeof raw == 'object' && !raw.width) { + if(raw.surface) + return new graphics.Image(raw.surface) + + if(raw.frames && Array.isArray(raw.frames) && raw.loop != null) + return makeAnim(wrapFrames(raw.frames), !!raw.loop); + + def anims = {}; + for(def [name, anim] of Object.entries(raw)){ + if(anim && Array.isArray(anim.frames)) + anims[name] = makeAnim(wrapFrames(anim.frames), !!anim.loop); + else if(anim && anim.surface) + anims[name] = new graphics.Image(anim.surface); + } + if(Object.keys(anims).length) return anims; + } + + throw new Error('Unsupported image structure from decoder'); + + }catch(e){ + log.error(`Error loading image ${path}: ${e.message}`); + throw e; + } +} + +var image = {} +image.dimensions = function() { + var width = 0, height = 0; + if (this.texture) { + width = this.texture.width; + height = this.texture.height; + } else if (this.cpu) { + width = this.cpu.width; + height = this.cpu.height; + } + return [width, height].scale([this.rect[2], this.rect[3]]) +} + +var spritesheet +var sheet_frames = [] +var sheetsize = 1024 + +/** +Pack multiple images into a single texture sheet for efficiency. +Currently unimplemented (returns immediately). +*/ +function pack_into_sheet(images) { + return + // This code is currently disabled with an immediate return. + // Implementation details commented out below. +} + +graphics.is_image = function(obj) { + if (obj.texture && obj.rect) return true +} + +graphics.texture_from_data = function(data) +{ + if (!(data instanceof ArrayBuffer)) return null + + var image = graphics.make_texture(data); + var img = make_handle(image) + + if (renderer_actor) img.gpu; + + return img; +} + +graphics.from_surface = function(surf) +{ + return make_handle(surf) +} + +graphics.from = function(id, data) +{ + if (typeof id != 'string') + throw new Error('Expected a string ID') + + if (data instanceof ArrayBuffer) + return graphics.texture_from_data(data) +} + +graphics.texture = function texture(path) { + if (path instanceof graphics.Image) return path + + if (typeof path != 'string') + throw new Error('need a string for graphics.texture') + + var parts = path.split(':') + var id = parts[0] + var animName = parts[1] + var frameIndex = parts[2] + + // Handle the case where animName is actually a frame index (e.g., "gears:0") + if (animName != null && frameIndex == null && !isNaN(parseInt(animName))) { + frameIndex = parseInt(animName) + animName = null + } + + if (!cache[id]) { + var ipath = res.find_image(id) + + if (!ipath) { + // If still not found, return notex + return graphics.texture('notex') + } + + var result = create_image(ipath) + cache[id] = result + } + + var cached = cache[id] + + // No further path specifiers and no frame index - return the whole thing + if (!animName && frameIndex == null) return cached + + // Handle frame index without animation name (e.g., "gears:0") + if (!animName && frameIndex != null) { + // If cached is a single animation (has .frames property) + if (cached.frames && Array.isArray(cached.frames)) { + var idx = parseInt(frameIndex) + if (isNaN(idx)) return cached + // Wrap the index + idx = idx % cached.frames.length + return cached.frames[idx].image + } + // If cached is a single Image, any frame index just returns the image + if (cached instanceof graphics.Image) { + return cached + } + } + + // If cached is a single Image, treat it as a single-frame animation + if (cached instanceof graphics.Image) { + if (frameIndex != null) { + // For single images, any frame index just returns the image + return cached + } + // animName without frameIndex for single image - return as single-frame array + return [cached] + } + + // If cached is a single animation (has .frames property) + if (cached.frames && Array.isArray(cached.frames)) { + if (frameIndex != null) { + var idx = parseInt(frameIndex) + if (isNaN(idx)) return cached + // Wrap the index + idx = idx % cached.frames.length + return cached.frames[idx].image + } + // Just animation name for single animation - return the animation + return cached + } + + // If cached is an object of multiple animations + if (typeof cached == 'object' && !cached.frames) { + var anim = cached[animName] + if (!anim) + throw new Error(`animation ${animName} not found in ${id}`) + + if (frameIndex != null) { + var idx = parseInt(frameIndex) + if (isNaN(idx)) return anim + + if (anim instanceof graphics.Image) { + // Single image animation - any frame index returns the image + return anim + } else if (anim.frames && Array.isArray(anim.frames)) { + // Multi-frame animation - wrap the index + idx = idx % anim.frames.length + return anim.frames[idx].image + } + } + + // Just animation name - return the animation + return anim + } + + return cached +} + +graphics.tex_hotreload = function tex_hotreload(file) { + var basename = file.split('/').pop().split('.')[0] + + // Check if this basename exists in our cache + if (!(basename in cache)) return + + // Find the full path for this image + var fullpath = res.find_image(basename) + if (!fullpath) return + + var img = create_image(fullpath) + var oldimg = cache[basename] + + // Preserve the GPU texture ID if it exists + var oldGPU = oldimg.gpu + + // Update the CPU surface data + oldimg.cpu = img.cpu + oldimg.surface = img.cpu + + // Clear GPU texture to force reload + oldimg.gpu = 0 + oldimg.texture = 0 + oldimg[LOADING] = false + + // Update dimensions + if (img.cpu) { + oldimg.width = img.cpu.width + oldimg.height = img.cpu.height + oldimg.rect = {x:0, y:0, width:img.cpu.width, height:img.cpu.height} + decorate_rect_px(oldimg) + } +} + +/** +Merges specific properties from nv into ov, using an array of property names. +*/ +function merge_objects(ov, nv, arr) { + arr.forEach(x => ov[x] = nv[x]) +} + +/** +Unimplemented function for creating a spritesheet out of multiple images. +*/ +function make_spritesheet(paths, width, height) { + return +} + +/** +Stores previously loaded fonts. Keyed by e.g. "path.ttf.16" -> fontObject. +*/ +var fontcache = {} +var datas = [] + +graphics.get_font = function get_font(path) { + if (typeof path != 'string') return path + var parts = path.split('.') + var size = 16 // default size + if (!isNaN(parts[1])) { + path = parts[0] + size = Number(parts[1]) + } + var fullpath = res.find_font(path) + if (!fullpath) throw new Error(`Cannot load font ${path}`) + + var fontstr = `${fullpath}.${size}` + if (fontcache[fontstr]) return fontcache[fontstr] + + var data = io.slurpbytes(fullpath) + var font = new staef.font(data, size) + + fontcache[fontstr] = font + + return font +} + +graphics.queue_sprite_mesh = function(queue) { + var sprites = queue.filter(x => x.type == 'sprite') + if (sprites.length == 0) return [] + var mesh = graphics.make_sprite_mesh(sprites) + for (var i = 0; i < sprites.length; i++) { + sprites[i].mesh = mesh + sprites[i].first_index = i*6 + sprites[i].num_indices = 6 + } + return [mesh.pos, mesh.uv, mesh.color, mesh.indices] +} + +return graphics diff --git a/mersenne.c b/mersenne.c new file mode 100644 index 00000000..6c522c22 --- /dev/null +++ b/mersenne.c @@ -0,0 +1,148 @@ +#include "quickjs.h" +#include "cell.h" +#include +#include "qjs_macros.h" + +// Random number generation constants for MT19937-64 +#define STATE_VECTOR_LENGTH 312 +#define STATE_VECTOR_M 156 +#define NN STATE_VECTOR_LENGTH +#define MM STATE_VECTOR_M +#define MATRIX_A 0xB5026F5AA96619E9ULL +#define UM 0xFFFFFFFF80000000ULL /* Most significant 33 bits */ +#define LM 0x7FFFFFFFULL /* Least significant 31 bits */ + +typedef struct tagMTRand { + uint64_t mt[STATE_VECTOR_LENGTH]; + int32_t index; +} MTRand; + +// Random number generation functions +static void m_seedRand(MTRand* rand, uint64_t seed) { + rand->mt[0] = seed; + for(rand->index = 1; rand->index < NN; rand->index++) { + rand->mt[rand->index] = (6364136223846793005ULL * (rand->mt[rand->index-1] ^ (rand->mt[rand->index-1] >> 62)) + rand->index); + } +} + +static int64_t genRandLong(MTRand* rand) { + int i; + uint64_t x; + static uint64_t mag01[2] = {0ULL, MATRIX_A}; + + if (rand->index >= NN) { /* generate NN words at one time */ + /* if init_genrand64() has not been called, */ + /* a default initial seed is used */ + if (rand->index == NN+1) + m_seedRand(rand, 5489ULL); + + for (i = 0; i < NN-MM; i++) { + x = (rand->mt[i] & UM) | (rand->mt[i+1] & LM); + rand->mt[i] = rand->mt[i+MM] ^ (x>>1) ^ mag01[(int)(x&1ULL)]; + } + for (; i < NN-1; i++) { + x = (rand->mt[i] & UM) | (rand->mt[i+1] & LM); + rand->mt[i] = rand->mt[i+(MM-NN)] ^ (x>>1) ^ mag01[(int)(x&1ULL)]; + } + x = (rand->mt[NN-1] & UM) | (rand->mt[0] & LM); + rand->mt[NN-1] = rand->mt[MM-1] ^ (x>>1) ^ mag01[(int)(x&1ULL)]; + + rand->index = 0; + } + + x = rand->mt[rand->index++]; + + x ^= (x >> 29) & 0x5555555555555555ULL; + x ^= (x << 17) & 0x71D67FFFEDA60000ULL; + x ^= (x << 37) & 0xFFF7EEE000000000ULL; + x ^= (x >> 43); + + return (int64_t)(x & 0x000FFFFFFFFFFFFFULL); /* return 52-bit value safe for JS */ +} + +static double genRand(MTRand* rand) { + /* generates a random number on [0,1)-real-interval */ + return (genRandLong(rand) >> 11) * (1.0/9007199254740992.0); +} + +/* JS Class Definition */ +static JSClassID js_mersenne_class_id; + +static void js_mersenne_finalizer(JSRuntime *rt, JSValue val) { + MTRand *mrand = JS_GetOpaque(val, js_mersenne_class_id); + js_free_rt(rt, mrand); +} + +static JSClassDef js_mersenne_class = { + "Mersenne", + .finalizer = js_mersenne_finalizer, +}; + +static MTRand *js2mersenne(JSContext *js, JSValue v) { + return JS_GetOpaque(v, js_mersenne_class_id); +} + +/* Methods */ +JSC_CCALL(mersenne_get, + MTRand *mrand = js2mersenne(js, self); + if (!mrand) return JS_ThrowTypeError(js, "Invalid mersenne context"); + return JS_NewFloat64(js, genRand(mrand)); +) + +static const JSCFunctionListEntry js_mersenne_funcs[] = { + JS_CFUNC_DEF("get", 0, js_mersenne_get), +}; + +/* Factory Function */ +static JSValue js_mersenne_use_call(JSContext *js, JSValueConst func_obj, + JSValueConst this_val, int argc, JSValueConst *argv) +{ + uint64_t seed; + + if (argc == 0 || JS_IsNull(argv[0])) { + // Use OS random + extern int randombytes(void *buf, size_t n); + randombytes(&seed, 8); + } else { + if (JS_ToFloat64(js, (double*)&seed, argv[0])) { + // Fallback to number if bigint fails or is not provided as bigint + double d; + if (JS_ToFloat64(js, &d, argv[0])) return JS_EXCEPTION; + seed = (uint64_t)d; + } + } + + MTRand *mrand = js_malloc(js, sizeof(MTRand)); + if (!mrand) return JS_ThrowOutOfMemory(js); + + m_seedRand(mrand, seed); + + JSValue obj = JS_NewObjectClass(js, js_mersenne_class_id); + if (JS_IsException(obj)) { + js_free(js, mrand); + return obj; + } + + JS_SetOpaque(obj, mrand); + + // Store seed as a read-only property + JS_DefinePropertyValueStr(js, obj, "seed", + JS_NewFloat64(js, seed), + JS_PROP_ENUMERABLE | JS_PROP_CONFIGURABLE // Read-only (no WRITABLE) + ); + + return obj; +} + +JSValue js_mersenne_use(JSContext *js) +{ + JS_NewClassID(&js_mersenne_class_id); + JS_NewClass(JS_GetRuntime(js), js_mersenne_class_id, &js_mersenne_class); + + JSValue proto = JS_NewObject(js); + JS_SetPropertyFunctionList(js, proto, js_mersenne_funcs, sizeof(js_mersenne_funcs)/sizeof(JSCFunctionListEntry)); + JS_SetClassProto(js, js_mersenne_class_id, proto); + + // Return the factory function + return JS_NewCFunction2(js, js_mersenne_use_call, "mersenne", 1, JS_CFUNC_generic, 0); +} diff --git a/qjs_math.c b/qjs_math.c index c26630c6..3c45b141 100644 --- a/qjs_math.c +++ b/qjs_math.c @@ -63,8 +63,7 @@ static int gcd(int a, int b) { // MATH FUNCTIONS - - +// Rotate a 2D point (or array of length 2) by the given angle (in turns) around an optional pivot. JSC_CCALL(math_rotate, HMM_Vec2 vec = js2vec2(js,argv[0]); double angle = js2angle(js, argv[1]); @@ -82,6 +81,7 @@ JSC_CCALL(math_rotate, return vec22js(js, vec); ) +// Return a normalized copy of the given numeric array. For 2D/3D/4D or arbitrary length. JSC_CCALL(math_norm, int len = JS_ArrayLength(js,argv[0]); @@ -100,6 +100,7 @@ JSC_CCALL(math_norm, ret = newarr; ) +// Compute the angle between two vectors (2D/3D/4D). JSC_CCALL(math_angle_between, int len = JS_ArrayLength(js,argv[0]); switch(len) { @@ -110,6 +111,7 @@ JSC_CCALL(math_angle_between, return JS_ThrowReferenceError(js, "Input array must have a length between 2 and 4."); ) +// Linear interpolation between two numbers: lerp(a, b, t). JSC_CCALL(math_lerp, double s = js2number(js,argv[0]); double f = js2number(js,argv[1]); @@ -118,14 +120,17 @@ JSC_CCALL(math_lerp, ret = number2js(js,(f-s)*t+s); ) +// Compute the greatest common divisor of two integers. JSC_CCALL(math_gcd, ret = number2js(js,gcd(js2number(js,argv[0]), js2number(js,argv[1]))); ) +// Compute the least common multiple of two integers. JSC_CCALL(math_lcm, double a = js2number(js,argv[0]); double b = js2number(js,argv[1]); ret = number2js(js,(a*b)/gcd(a,b)); ) +// Clamp a number between low and high. clamp(value, low, high). JSC_CCALL(math_clamp, double x = js2number(js,argv[0]); double l = js2number(js,argv[1]); @@ -133,6 +138,7 @@ JSC_CCALL(math_clamp, return number2js(js,x > h ? h : x < l ? l : x); ) +// Compute the signed distance between two angles in 'turn' units, e.g. 0..1 range. JSC_CCALL(math_angledist, double a1 = js2number(js,argv[0]); double a2 = js2number(js,argv[1]); @@ -150,6 +156,7 @@ JSC_CCALL(math_angledist, return number2js(js,dist); ) +// Apply a random +/- percentage noise to a number. Example: jitter(100, 0.05) -> ~95..105. JSC_CCALL(math_jitter, double n = js2number(js,argv[0]); double pct = js2number(js,argv[1]); @@ -158,6 +165,7 @@ JSC_CCALL(math_jitter, // return number2js(js,n + (rand_range(js,-pct,pct)*n)); ) +// Compute the arithmetic mean of an array of numbers. JSC_CCALL(math_mean, double len = JS_ArrayLength(js,argv[0]); double sum = 0; @@ -167,6 +175,7 @@ JSC_CCALL(math_mean, return number2js(js,sum/len); ) +// Sum all elements of an array of numbers. JSC_CCALL(math_sum, double sum = 0.0; int len = JS_ArrayLength(js,argv[0]); @@ -176,6 +185,7 @@ JSC_CCALL(math_sum, return number2js(js,sum); ) +// Compute standard deviation of an array of numbers. JSC_CCALL(math_sigma, int len = JS_ArrayLength(js,argv[0]); double sum = 0; @@ -190,6 +200,7 @@ JSC_CCALL(math_sigma, return number2js(js,sqrt(sum/len)); ) +// Compute the median of an array of numbers. JSC_CCALL(math_median, int len = JS_ArrayLength(js,argv[0]); double vals[len]; @@ -213,8 +224,10 @@ JSC_CCALL(math_median, return number2js(js,vals[len/2]); ) +// Return the length of a vector (i.e. sqrt of sum of squares). JSC_CCALL(math_length, return number2js(js,arr_vec_length(js,argv[0])); ) +// Return an array of points from a start to an end, spaced out by a certain distance. JSC_CCALL(math_from_to, double start = js2number(js,argv[0]); double end = js2number(js,argv[1]); @@ -232,7 +245,7 @@ JSC_CCALL(math_from_to, return jsarr; ) - +// Compute the dot product between two numeric arrays, returning a scalar. Extra elements are ignored. JSC_CCALL(math_dot, size_t alen, blen; float *a = js2floats(js,argv[0], &alen); @@ -247,6 +260,7 @@ JSC_CCALL(math_dot, return number2js(js,dot); ) +// Project one vector onto another, returning a new array of the same dimension. JSC_CCALL(math_project, size_t alen, blen; float *a = js2floats(js, argv[0], &alen); @@ -292,6 +306,7 @@ JSC_CCALL(math_project, free(proj); ) +// Compute the midpoint of two arrays of numbers. Only the first two entries are used if 2D is intended. JSC_CCALL(math_midpoint, size_t alen, blen; float *a = js2floats(js, argv[0], &alen); @@ -327,6 +342,7 @@ JSC_CCALL(math_midpoint, free(m); ) +// Reflect a vector across a plane normal. Both arguments must be numeric arrays. JSC_CCALL(math_reflect, size_t alen, blen; float *a = js2floats(js, argv[0], &alen); @@ -371,6 +387,7 @@ JSC_CCALL(math_reflect, free(result); ) +// Compute the normalized direction vector from the first array to the second. JSC_CCALL(math_direction, size_t alen, blen; float *a = js2floats(js, argv[0], &alen); @@ -416,6 +433,7 @@ JSC_CCALL(math_direction, free(dir); ) +// Given a 2D vector, return its angle from the X-axis in radians or some chosen units. JSC_CCALL(math_angle, size_t len; float *v = js2floats(js, argv[0], &len); @@ -431,6 +449,7 @@ JSC_CCALL(math_angle, free(v); ) +// Compute the Euclidean distance between two numeric arrays of matching length. JSC_CCALL(math_distance, size_t alen, blen; float *a = js2floats(js, argv[0], &alen); diff --git a/resources.cm b/resources.cm new file mode 100644 index 00000000..65916c08 --- /dev/null +++ b/resources.cm @@ -0,0 +1,166 @@ +var io = use('cellfs') + +Object.defineProperty(Function.prototype, "hashify", { + value: function () { + var hash = {} + var fn = this + function hashified(...args) { + var key = args[0] + if (hash[key] == null) hash[key] = fn(...args) + return hash[key] + } + return hashified + }, +}) + +// Merge of the old resources.js and packer.js functionalities +var Resources = {} + +// Recognized resource extensions +Resources.scripts = ["js"] +Resources.images = ["qoi", "png", "gif", "jpg", "jpeg", "ase", "aseprite"] +Resources.sounds = ["wav", "flac", "mp3", "qoa"] +Resources.fonts = ["ttf"] + +// Helper function: get extension from path in lowercase (e.g., "image.png" -> "png") +function getExtension(path) { + var idx = path.lastIndexOf('.') + if (idx < 0) return '' + return path.substring(idx + 1).toLowerCase() +} + +// Return true if ext is in at least one of the recognized lists +function isRecognizedExtension(ext) { + if (!ext) return false + if (Resources.scripts.includes(ext)) return true + if (Resources.images.includes(ext)) return true + if (Resources.sounds.includes(ext)) return true + if (Resources.fonts.includes(ext)) return true + if (Resources.lib.includes('.' + ext)) return true // for .so or .dll + return false +} + +function find_in_path(filename, exts = []) { + if (typeof filename != 'string') return null + + if (filename.includes('.')) { + var candidate = filename // possibly need "/" ? + if (io.exists(candidate) && !io.is_directory(candidate)) return candidate + return null + } + + // Only check extensions if exts is provided and not empty + if (exts.length > 0) { + for (var ext of exts) { + var candidate = filename + '.' + ext + if (io.exists(candidate) && !io.is_directory(candidate)) return candidate + } + } else { + // Fallback to extensionless file only if no extensions are specified + var candidate = filename + if (io.exists(candidate) && !io.is_directory(candidate)) return candidate + } + return null +} + +// Return a canonical path (the real directory plus the path) +Resources.canonical = function(file) { + return io.realdir(file) + file +} + +// The resource finders +Resources.find_image = function(file) { + return find_in_path(file, Resources.images) +}.hashify() + +Resources.find_sound = function(file) { + return find_in_path(file, Resources.sounds) +}.hashify() + +Resources.find_script = function(file) { + return find_in_path(file, Resources.scripts) +}.hashify() + +Resources.find_font = function(file) { + return find_in_path(file, Resources.fonts) +}.hashify() + +// .prosperonignore reading helper +function read_ignore(dir) { + var path = dir + '/.prosperonignore' + var patterns = [] + if (io.exists(path)) { + var lines = io.slurp(path).split('\n') + for (var line of lines) { + line = line.trim() + if (!line || line.startsWith('#')) continue + patterns.push(line) + } + } + return patterns +} + +// Return a list of recognized files in the directory (and subdirectories), +// skipping those matched by .prosperonignore. Directory paths are skipped. +Resources.getAllFiles = function(dir = "") { + var patterns = read_ignore(dir) + var all = io.globfs(patterns, dir) + var results = [] + for (var f of all) { + var fullPath = dir + '/' + f + try { + var st = io.stat(fullPath) + // skip directories (filesize=0) or unrecognized extension + if (!st.filesize) continue + var ext = getExtension(f) + if (!isRecognizedExtension(ext)) continue + results.push(fullPath) + } catch(e) {} + } + return results +} +Resources.getAllFiles[cell.DOC] = ` +Return a list of recognized files in the given directory that are not matched by +.prosperonignore, skipping directories. Recognized extensions include scripts, +images, sounds, fonts, and libs. + +:param dir: The directory to search. +:return: An array of recognized file paths. +` + +// Categorize files by resource type +Resources.gatherStats = function(filePaths) { + var stats = { + scripts:0, images:0, sounds:0, fonts:0, lib:0, other:0, total:filePaths.length + } + for (var path of filePaths) { + var ext = getExtension(path) + if (Resources.scripts.includes(ext)) { + stats.scripts++ + continue + } + if (Resources.images.includes(ext)) { + stats.images++ + continue + } + if (Resources.sounds.includes(ext)) { + stats.sounds++ + continue + } + if (Resources.fonts.includes(ext)) { + stats.fonts++ + continue + } + stats.other++ + } + return stats +} +Resources.gatherStats[cell.DOC] = ` +Analyze a list of recognized files and categorize them by scripts, images, sounds, +fonts, libs, or other. Return a stats object with these counts and the total. + +:param filePaths: An array of file paths to analyze. +:return: { scripts, images, sounds, fonts, lib, other, total } +` + +return Resources