// // platform.js // // Created by Seiji Emery on 8/19/15 // Copyright 2015 High Fidelity, Inc. // // Entity stress test / procedural demo. // Spawns a platform under your avatar made up of randomly sized and colored boxes or spheres. The platform follows your avatar // around, and comes with a UI to update the platform's properties (radius, entity density, color distribution, etc) in real time. // // Distributed under the Apache License, Version 2.0. // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // // UI and debug console implemented using uiwidgets / 2d overlays Script.include("../../libraries/uiwidgets.js"); if (typeof(UI) === 'undefined') { // backup link in case the user downloaded this somewhere print("Missing library script -- loading from public.highfidelity.io"); Script.include('http://public.highfidelity.io/scripts/libraries/uiwidgets.js'); if (typeof(UI) === 'undefined') { print("Cannot load UIWidgets library -- check your internet connection", COLORS.RED); throw new Error("Could not load uiwidgets.js"); } } // Platform script (function () { var SCRIPT_NAME = "platform.js"; var USE_DEBUG_LOG = true; // Turns on the 2dOverlay-based debug log. If false, just redirects to print. var NUM_DEBUG_LOG_LINES = 10; var LOG_ENTITY_CREATION_MESSAGES = false; // detailed debugging (init) var LOG_UPDATE_STATUS_MESSAGES = false; // detailed debugging (startup) var MAX_UPDATE_INTERVAL = 0.2; // restrict to 5 updates / sec var AVATAR_HEIGHT_OFFSET = 1.5; // offset to make the platform spawn under your feet. Might need to be adjusted for unusually proportioned avatars. var USE_ENTITY_TIMEOUTS = true; var ENTITY_TIMEOUT_DURATION = 30.0; // kill entities in 30 secs if they don't get any updates var ENTITY_REFRESH_INTERVAL = 10.0; // poke the entities every 10s so they don't die until we're done with them // Initial state var NUM_PLATFORM_ENTITIES = 400; var RADIUS = 5.0; // Defines min/max for onscreen platform radius, density, and entity width/height/depth sliders. // Color limits are hardcoded at [0, 255]. var PLATFORM_RADIUS_RANGE = [ 1.0, 15.0 ]; var PLATFORM_DENSITY_RANGE = [ 0.0, 35.0 ]; // do NOT increase this above 40! (~20k limit). Entity count = Math.PI * radius * radius * density. var PLATFORM_SHAPE_DIMENSIONS_RANGE = [ 0.001, 2.0 ]; // axis-aligned entity dimension limits // Utils (function () { if (typeof(Math.randRange) === 'undefined') { Math.randRange = function (min, max) { return Math.random() * (max - min) + min; } } if (typeof(Math.randInt) === 'undefined') { Math.randInt = function (n) { return Math.floor(Math.random() * n) | 0; } } function fromComponents (r, g, b, a) { this.red = r; this.green = g; this.blue = b; this.alpha = a || 1.0; } function fromHex (c) { this.red = parseInt(c[1] + c[2], 16); this.green = parseInt(c[3] + c[4], 16); this.blue = parseInt(c[5] + c[6], 16); } var Color = this.Color = function () { if (arguments.length >= 3) { fromComponents.apply(this, arguments); } else if (arguments.length == 1 && arguments[0].length == 7 && arguments[0][0] == '#') { fromHex.apply(this, arguments); } else { throw new Error("Invalid arguments to new Color(): " + JSON.stringify(arguments)); } } Color.prototype.toString = function () { return "[Color: " + JSON.stringify(this) + "]"; } })(); // RNG models (function () { /// Encapsulates a simple color model that generates colors using a linear, pseudo-random color distribution. var RandomColorModel = this.RandomColorModel = function () { this.shadeRange = 0; // = 200; this.minColor = 55; // = 100; this.redRange = 255; // = 200; this.greenRange = 0; // = 10; this.blueRange = 0; // = 0; }; /// Generates 4 numbers in [0, 1] corresponding to each color attribute (uniform shade and additive red, green, blue). /// This is done in a separate step from actually generating the colors, since it allows us to either A) completely /// rebuild / re-randomize the color values, or B) reuse the RNG values but with different color parameters, which /// enables us to do realtime color editing on the same visuals (awesome!). RandomColorModel.prototype.generateSeed = function () { return [ Math.random(), Math.random(), Math.random(), Math.random() ]; }; /// Takes a random 'seed' (4 floats from this.generateSeed()) and calculates a pseudo-random /// color by combining that with the color model's current parameters. RandomColorModel.prototype.getRandom = function (r) { // logMessage("color seed values " + JSON.stringify(r)); var shade = Math.min(255, this.minColor + r[0] * this.shadeRange); // No clamping on the color components, so they may overflow. // However, this creates some pretty interesting visuals, so we're not "fixing" this. var color = { red: shade + r[1] * this.redRange, green: shade + r[2] * this.greenRange, blue: shade + r[3] * this.blueRange }; // logMessage("this: " + JSON.stringify(this)); // logMessage("color: " + JSON.stringify(color), COLORS.RED); return color; }; /// Custom property iterator used to setup UI (sliders, etc) RandomColorModel.prototype.setupUI = function (callback) { var _this = this; [ ['shadeRange', 'shade range'], ['minColor', 'shade min'], ['redRange', 'red (additive)'], ['greenRange', 'green (additive)'], ['blueRange', 'blue (additive)'] ].forEach(function (v) { // name, value, min, max, onValueChanged callback(v[1], _this[v[0]], 0, 255, function (value) { _this[v[0]] = value }); }); } /// Generates pseudo-random dimensions for our cubes / shapes. var RandomShapeModel = this.RandomShapeModel = function () { this.widthRange = [ 0.3, 0.7 ]; this.depthRange = [ 0.5, 0.8 ]; this.heightRange = [ 0.01, 0.08 ]; }; /// Generates 3 seed numbers in [0, 1] RandomShapeModel.prototype.generateSeed = function () { return [ Math.random(), Math.random(), Math.random() ]; } /// Combines seed values with width/height/depth ranges to produce vec3 dimensions for a cube / sphere. RandomShapeModel.prototype.getRandom = function (r) { return { x: r[0] * (this.widthRange[1] - this.widthRange[0]) + this.widthRange[0], y: r[1] * (this.heightRange[1] - this.heightRange[0]) + this.heightRange[0], z: r[2] * (this.depthRange[1] - this.depthRange[0]) + this.depthRange[0] }; } /// Custom property iterator used to setup UI (sliders, etc) RandomShapeModel.prototype.setupUI = function (callback) { var _this = this; var dimensionsMin = PLATFORM_SHAPE_DIMENSIONS_RANGE[0]; var dimensionsMax = PLATFORM_SHAPE_DIMENSIONS_RANGE[1]; [ ['widthRange', 'width'], ['depthRange', 'depth'], ['heightRange', 'height'] ].forEach(function (v) { // name, value, min, max, onValueChanged callback(v[1], _this[v[0]], dimensionsMin, dimensionsMax, function (value) { _this[v[0]] = value }); }); } /// Combines color + shape PRNG models and hides their implementation details. var RandomAttribModel = this.RandomAttribModel = function () { this.colorModel = new RandomColorModel(); this.shapeModel = new RandomShapeModel(); } /// Completely re-randomizes obj's `color` and `dimensions` parameters based on the current model params. RandomAttribModel.prototype.randomizeShapeAndColor = function (obj) { // logMessage("randomizing " + JSON.stringify(obj)); obj._colorSeed = this.colorModel.generateSeed(); obj._shapeSeed = this.shapeModel.generateSeed(); this.updateShapeAndColor(obj); // logMessage("color seed: " + JSON.stringify(obj._colorSeed), COLORS.RED); // logMessage("randomized color: " + JSON.stringify(obj.color), COLORS.RED); // logMessage("randomized: " + JSON.stringify(obj)); return obj; } /// Updates obj's `color` and `dimensions` params to use the current model params. /// Reuses hidden seed attribs; _must_ have called randomizeShapeAndColor(obj) at some point before /// calling this. RandomAttribModel.prototype.updateShapeAndColor = function (obj) { try { // logMessage("update shape and color: " + this.colorModel); obj.color = this.colorModel.getRandom(obj._colorSeed); obj.dimensions = this.shapeModel.getRandom(obj._shapeSeed); } catch (e) { logMessage("update shape / color failed", COLORS.RED); logMessage('' + e, COLORS.RED); logMessage("obj._colorSeed = " + JSON.stringify(obj._colorSeed)); logMessage("obj._shapeSeed = " + JSON.stringify(obj._shapeSeed)); // logMessage("obj = " + JSON.stringify(obj)); throw e; } return obj; } })(); // Status / logging UI (ignore this) (function () { var COLORS = this.COLORS = { 'GREEN': new Color("#2D870C"), 'RED': new Color("#AF1E07"), 'LIGHT_GRAY': new Color("#CCCCCC"), 'DARK_GRAY': new Color("#4E4E4E") }; function buildDebugLog () { var LINE_WIDTH = 400; var LINE_HEIGHT = 20; var lines = []; var lineIndex = 0; for (var i = 0; i < NUM_DEBUG_LOG_LINES; ++i) { lines.push(new UI.Label({ text: " ", visible: false, width: LINE_WIDTH, height: LINE_HEIGHT, })); } var title = new UI.Label({ text: SCRIPT_NAME, visible: true, width: LINE_WIDTH, height: LINE_HEIGHT, }); var overlay = new UI.Box({ visible: true, width: LINE_WIDTH, height: 0, backgroundColor: COLORS.DARK_GRAY, backgroundAlpha: 0.3 }); overlay.setPosition(280, 10); relayoutFrom(0); UI.updateLayout(); function relayoutFrom (n) { var layoutPos = { x: overlay.position.x, y: overlay.position.y }; title.setPosition(layoutPos.x, layoutPos.y); layoutPos.y += LINE_HEIGHT; // for (var i = n; i >= 0; --i) { for (var i = n + 1; i < lines.length; ++i) { if (lines[i].visible) { lines[i].setPosition(layoutPos.x, layoutPos.y); layoutPos.y += LINE_HEIGHT; } } // for (var i = lines.length - 1; i > n; --i) { for (var i = 0; i <= n; ++i) { if (lines[i].visible) { lines[i].setPosition(layoutPos.x, layoutPos.y); layoutPos.y += LINE_HEIGHT; } } overlay.height = (layoutPos.y - overlay.position.y + 10); overlay.getOverlay().update({ height: overlay.height }); } this.logMessage = function (text, color, alpha) { lines[lineIndex].setVisible(true); relayoutFrom(lineIndex); lines[lineIndex].getOverlay().update({ text: text, visible: true, color: color || COLORS.LIGHT_GRAY, alpha: alpha !== undefined ? alpha : 1.0, x: lines[lineIndex].position.x, y: lines[lineIndex].position.y }); lineIndex = (lineIndex + 1) % lines.length; UI.updateLayout(); } } if (USE_DEBUG_LOG) { buildDebugLog(); } else { this.logMessage = function (msg) { print(SCRIPT_NAME + ": " + msg); } } })(); // Utils (ignore) (function () { // Utility function var withDefaults = this.withDefaults = function (properties, defaults) { // logMessage("withDefaults: " + JSON.stringify(properties) + JSON.stringify(defaults)); properties = properties || {}; if (defaults) { for (var k in defaults) { properties[k] = defaults[k]; } } return properties; } var withReadonlyProp = this.withReadonlyProp = function (propname, value, obj) { Object.defineProperty(obj, propname, { value: value, writable: false }); return obj; } // Math utils if (typeof(Math.randRange) === 'undefined') { Math.randRange = function (min, max) { return Math.random() * (max - min) + min; } } if (typeof(Math.randInt) === 'undefined') { Math.randInt = function (n) { return Math.floor(Math.random() * n) | 0; } } /// Random distrib: Get a random point within a circle on the xz plane with radius r, center p. this.randomCirclePoint = function (r, pos) { var a = Math.random(), b = Math.random(); if (b < a) { var tmp = b; b = a; a = tmp; } var point = { x: pos.x + b * r * Math.cos(2 * Math.PI * a / b), y: pos.y, z: pos.z + b * r * Math.sin(2 * Math.PI * a / b) }; if (LOG_ENTITY_CREATION_MESSAGES) { // logMessage("input params: " + JSON.stringify({ radius: r, position: pos }), COLORS.GREEN); // logMessage("a = " + a + ", b = " + b); logMessage("generated point: " + JSON.stringify(point), COLORS.RED); } return point; } // Entity utils. NOT using overlayManager for... reasons >.> var makeEntity = this.makeEntity = function (properties) { if (LOG_ENTITY_CREATION_MESSAGES) { logMessage("Creating entity: " + JSON.stringify(properties)); } var entity = Entities.addEntity(properties); return withReadonlyProp("type", properties.type, { update: function (properties) { Entities.editEntity(entity, properties); }, destroy: function () { Entities.deleteEntity(entity) }, getId: function () { return entity; } }); } // this.makeLight = function (properties) { // return makeEntity(withDefaults(properties, { // type: "Light", // isSpotlight: false, // diffuseColor: { red: 255, green: 100, blue: 100 }, // ambientColor: { red: 200, green: 80, blue: 80 } // })); // } this.makeBox = function (properties) { // logMessage("Creating box: " + JSON.stringify(properties)); return makeEntity(withDefaults(properties, { type: "Box" })); } })(); // Platform (function () { /// Encapsulates a platform 'piece'. Owns an entity (`box`), and handles destruction and some other state. var PlatformComponent = this.PlatformComponent = function (properties) { // logMessage("Platform component initialized with " + Object.keys(properties), COLORS.GREEN); this.position = properties.position || null; this.color = properties.color || null; this.dimensions = properties.dimensions || null; this.entityType = properties.type || "Box"; // logMessage("Spawning with type: '" + this.entityType + "' (properties.type = '" + properties.type + "')", COLORS.GREEN); if (properties._colorSeed) this._colorSeed = properties._colorSeed; if (properties._shapeSeed) this._shapeSeed = properties._shapeSeed; // logMessage("dimensions: " + JSON.stringify(this.dimensions)); // logMessage("color: " + JSON.stringify(this.color)); this.cachedEntity = null; this.activeEntity = this.spawnEntity(this.entityType); }; PlatformComponent.prototype.spawnEntity = function (type) { return makeEntity({ type: type, position: this.position, dimensions: this.dimensions, color: this.color, lifetime: USE_ENTITY_TIMEOUTS ? ENTITY_TIMEOUT_DURATION : -1.0, alpha: 0.5 }); } if (USE_ENTITY_TIMEOUTS) { PlatformComponent.prototype.pokeEntity = function () { // Kinda inefficient, but there's no way to get around this :/ var age = Entities.getEntityProperties(this.activeEntity.getId()).age; this.activeEntity.update({ lifetime: ENTITY_TIMEOUT_DURATION + age }); } } else { PlatformComponent.prototype.pokeEntity = function () {} } /// Updates platform to be at position p, and calls .update() with the current /// position, color, and dimensions parameters. PlatformComponent.prototype.update = function (position) { if (position) this.position = position; // logMessage("updating with " + JSON.stringify(this)); this.activeEntity.update(this); } function swap (a, b) { var tmp = a; a = b; b = tmp; } PlatformComponent.prototype.swapEntityType = function (newType) { if (this.entityType !== newType) { this.entityType = newType; // logMessage("Destroying active entity and rebuilding it (newtype = '" + newType + "')"); if (this.activeEntity) { this.activeEntity.destroy(); } this.activeEntity = this.spawnEntity(newType); // if (this.cachedEntity && this.cachedEntity.type == newType) { // this.cachedEntity.update({ visible: true }); // this.activeEntity.update({ visible: false }); // swap(this.cachedEntity, this.activeEntity); // this.update(this.position); // } else { // this.activeEntity.update({ visible: false }); // this.cachedEntity = this.activeEntity; // this.activeEntity = spawnEntity(newType); // } } } /// Swap state with another component PlatformComponent.prototype.swap = function (other) { swap(this.position, other.position); swap(this.dimensions, other.dimensions); swap(this.color, other.color); swap(this.entityType, other.entityType); swap(this.activeEntity, other.activeEntity); swap(this._colorSeed, other._colorSeed); swap(this._shapeSeed, other._shapeSeed); } PlatformComponent.prototype.destroy = function () { if (this.activeEntity) { this.activeEntity.destroy(); this.activeEntity = null; } if (this.cachedEntity) { this.cachedEntity.destroy(); this.cachedEntity = null; } } // util function inRange (p1, p2, radius) { return Vec3.distance(p1, p2) < Math.abs(radius); } /// Encapsulates a moving platform that follows the avatar around (mostly). var DynamicPlatform = this.DynamicPlatform = function (n, position, radius) { this.position = position; this.radius = radius; this.randomizer = new RandomAttribModel(); this.boxType = "Box"; this.boxTypes = [ "Box", "Sphere" ]; logMessage("Spawning " + n + " entities", COLORS.GREEN); var boxes = this.boxes = []; while (n > 0) { boxes.push(this.spawnEntity()); --n; } this.targetDensity = this.getEntityDensity(); this.pendingUpdates = {}; this.updateTimer = 0.0; this.platformHeight = position.y; this.oldPos = { x: position.x, y: position.y, z: position.z }; this.oldRadius = radius; // this.sendPokes(); } DynamicPlatform.prototype.toString = function () { return "[DynamicPlatform (" + this.boxes.length + " entities)]"; } DynamicPlatform.prototype.spawnEntity = function () { // logMessage("Called spawn entity. this.boxType = '" + this.boxType + "'") var properties = { position: this.randomPoint(), type: this.boxType }; this.randomizer.randomizeShapeAndColor(properties); return new PlatformComponent(properties); } DynamicPlatform.prototype.updateEntityAttribs = function () { var _this = this; this.setPendingUpdate('updateEntityAttribs', function () { // logMessage("updating model", COLORS.GREEN); _this.boxes.forEach(function (box) { this.randomizer.updateShapeAndColor(box); box.update(); }, _this); }); } DynamicPlatform.prototype.toggleBoxType = function () { var _this = this; this.setPendingUpdate('toggleBoxType', function () { // Swap / cycle through types: find index of current type and set next type to idx+1 for (var idx = 0; idx < _this.boxTypes.length; ++idx) { if (_this.boxTypes[idx] === _this.boxType) { var nextIndex = (idx + 1) % _this.boxTypes.length; logMessage("swapping box type from '" + _this.boxType + "' to '" + _this.boxTypes[nextIndex] + "'", COLORS.GREEN); _this.boxType = _this.boxTypes[nextIndex]; break; } } _this.boxes.forEach(function (box) { box.swapEntityType(_this.boxType); }, _this); }); } DynamicPlatform.prototype.getBoxType = function () { return this.boxType; } // if (USE_ENTITY_TIMEOUTS) { // DynamicPlatform.prototype.sendPokes = function () { // var _this = this; // function poke () { // logMessage("Poking entities so they don't die", COLORS.GREEN); // _this.boxes.forEach(function (box) { // box.pokeEntity(); // }, _this); // if (_this.pendingUpdates['keepalive']) { // logMessage("previous timer: " + _this.pendingUpdates['keepalive'].timer + "; new timer: " + ENTITY_REFRESH_INTERVAL) // } // _this.pendingUpdates['keepalive'] = { // callback: poke, // timer: ENTITY_REFRESH_INTERVAL, // skippedUpdates: 0 // }; // // _this.setPendingUpdate('keepalive', poke); // // _this.pendingUpdates['keepalive'].timer = ENTITY_REFRESH_INTERVAL; // } // poke(); // } // } else { // DynamicPlatform.prototype.sendPokes = function () {}; // } /// Queue impl that uses the update loop to limit potentially expensive updates to only execute every x seconds (default: 200 ms). /// This is to prevent UI code from running full entity updates every 10 ms (or whatever). DynamicPlatform.prototype.setPendingUpdate = function (name, callback) { if (!this.pendingUpdates[name]) { // logMessage("Queued update for " + name, COLORS.GREEN); this.pendingUpdates[name] = { callback: callback, timer: 0.0, skippedUpdates: 0 } } else { // logMessage("Deferred update for " + name, COLORS.GREEN); this.pendingUpdates[name].callback = callback; this.pendingUpdates[name].skippedUpdates++; // logMessage("scheduling update for \"" + name + "\" to run in " + this.pendingUpdates[name].timer + " seconds"); } } /// Runs all queued updates as soon as they can execute (each one has a cooldown timer). DynamicPlatform.prototype.processPendingUpdates = function (dt) { for (var k in this.pendingUpdates) { if (this.pendingUpdates[k].timer >= 0.0) this.pendingUpdates[k].timer -= dt; if (this.pendingUpdates[k].callback && this.pendingUpdates[k].timer < 0.0) { // logMessage("Dispatching update for " + k); try { this.pendingUpdates[k].callback(); } catch (e) { logMessage("update for \"" + k + "\" failed: " + e, COLORS.RED); } this.pendingUpdates[k].timer = MAX_UPDATE_INTERVAL; this.pendingUpdates[k].skippedUpdates = 0; this.pendingUpdates[k].callback = null; } else { // logMessage("Deferred update for " + k + " for " + this.pendingUpdates[k].timer + " seconds"); } } } /// Updates the platform based on the avatar's current position (spawning / despawning entities as needed), /// and calls processPendingUpdates() once this is done. /// Does NOT have any update interval limits (it just updates every time it gets run), but these are not full /// updates (they're incremental), so the network will not get flooded so long as the avatar is moving at a /// normal walking / flying speed. DynamicPlatform.prototype.updatePosition = function (dt, position) { // logMessage("updating " + this); position.y = this.platformHeight; this.position = position; var toUpdate = []; this.boxes.forEach(function (box, i) { // if (Math.abs(box.position.y - position.y) > HEIGHT_TOLERANCE || !inRange(box, position, radius)) { if (!inRange(box.position, this.position, this.radius)) { toUpdate.push(i); } }, this); var MAX_TRIES = toUpdate.length * 8; var tries = MAX_TRIES; var moved = 0; var recalcs = 0; toUpdate.forEach(function (index) { if ((index % 2 == 0) || tries > 0) { do { var randomPoint = this.randomPoint(this.position, this.radius); ++recalcs } while (--tries > 0 && inRange(randomPoint, this.oldPos, this.oldRadiuss)); if (LOG_UPDATE_STATUS_MESSAGES && tries <= 0) { logMessage("updatePlatform() gave up after " + MAX_TRIES + " iterations (" + moved + " / " + toUpdate.length + " successful updates)", COLORS.RED); logMessage("old pos: " + JSON.stringify(this.oldPos) + ", old radius: " + this.oldRadius); logMessage("new pos: " + JSON.stringify(this.position) + ", new radius: " + this.radius); } } else { var randomPoint = this.randomPoint(position, this.radius); } this.randomizer.randomizeShapeAndColor(this.boxes[index]); this.boxes[index].update(randomPoint); // this.boxes[index].setValues({ // position: randomPoint, // // dimensions: this.randomDimensions(), // // color: this.randomColor() // }); ++moved; }, this); recalcs = recalcs - toUpdate.length; this.oldPos = position; this.oldRadius = this.radius; if (LOG_UPDATE_STATUS_MESSAGES && toUpdate.length > 0) { logMessage("updated " + toUpdate.length + " entities w/ " + recalcs + " recalcs"); } } DynamicPlatform.prototype.update = function (dt, position) { this.updatePosition(dt, position); this.processPendingUpdates(dt); this.sendPokes(dt); } if (USE_ENTITY_TIMEOUTS) { DynamicPlatform.prototype.sendPokes = function (dt) { logMessage("starting keepalive", COLORS.GREEN); // logMessage("dt = " + dt, COLORS.RED); // var original = this.sendPokes; var pokeTimer = 0.0; this.sendPokes = function (dt) { // logMessage("dt = " + dt); if ((pokeTimer -= dt) < 0.0) { // logMessage("Poking entities so they don't die", COLORS.GREEN); this.boxes.forEach(function (box) { box.pokeEntity(); }, this); pokeTimer = ENTITY_REFRESH_INTERVAL; } else { // logMessage("Poking entities in " + pokeTimer + " seconds"); } } // logMessage("this.sendPokes === past this.sendPokes? " + (this.sendPokes === original), COLORS.GREEN); this.sendPokes(dt); } } else { DynamicPlatform.prototype.sendPokes = function () {}; } DynamicPlatform.prototype.getEntityCount = function () { return this.boxes.length; } DynamicPlatform.prototype.getEntityCountWithRadius = function (radius) { var est = Math.floor((radius * radius) / (this.radius * this.radius) * this.getEntityCount()); var actual = Math.floor(Math.PI * radius * radius * this.getEntityDensity()); if (est != actual) { logMessage("assert failed: getEntityCountWithRadius() -- est " + est + " != actual " + actual); } return est; } DynamicPlatform.prototype.getEntityCountWithDensity = function (density) { return Math.floor(Math.PI * this.radius * this.radius * density); } /// Sets the entity count to n. Don't call this directly -- use setRadius / density instead. DynamicPlatform.prototype.setEntityCount = function (n) { if (n > this.boxes.length) { // logMessage("Setting entity count to " + n + " (adding " + (n - this.boxes.length) + " entities)", COLORS.GREEN); // Spawn new boxes n = n - this.boxes.length; for (; n > 0; --n) { // var properties = { position: this.randomPoint() }; // this.randomizer.randomizeShapeAndColor(properties); // this.boxes.push(new PlatformComponent(properties)); this.boxes.push(this.spawnEntity()); } } else if (n < this.boxes.length) { // logMessage("Setting entity count to " + n + " (removing " + (this.boxes.length - n) + " entities)", COLORS.GREEN); // Destroy random boxes (technically, the most recent ones, but it should be sorta random) n = this.boxes.length - n; for (; n > 0; --n) { this.boxes.pop().destroy(); } } } /// Calculate the entity density based on radial surface area. DynamicPlatform.prototype.getEntityDensity = function () { return (this.boxes.length * 1.0) / (Math.PI * this.radius * this.radius); } /// Queues a setDensity update. This is expensive, so we don't call it directly from UI. DynamicPlatform.prototype.setDensityOnNextUpdate = function (density) { var _this = this; this.targetDensity = density; this.setPendingUpdate('density', function () { _this.updateEntityDensity(density); }); } DynamicPlatform.prototype.updateEntityDensity = function (density) { this.setEntityCount(Math.floor(density * Math.PI * this.radius * this.radius)); } DynamicPlatform.prototype.getRadius = function () { return this.radius; } /// Queues a setRadius update. This is expensive, so we don't call it directly from UI. DynamicPlatform.prototype.setRadiusOnNextUpdate = function (radius) { var _this = this; this.setPendingUpdate('radius', function () { _this.setRadius(radius); }); } var DEBUG_RADIUS_RECALC = false; DynamicPlatform.prototype.setRadius = function (radius) { if (radius < this.radius) { // Reduce case // logMessage("Setting radius to " + radius + " (shrink by " + (this.radius - radius) + ")", COLORS.GREEN ); this.radius = radius; // Remove all entities outside of current bounds. Requires swapping, since we want to maintain a contiguous array. // Algorithm: two pointers at front and back. We traverse fwd and back, swapping elems so that all entities in bounds // are at the front of the array, and all entities out of bounds are at the back. We then pop + destroy all entities // at the back to reduce the entity count. var count = this.boxes.length; var toDelete = 0; var swapList = []; if (DEBUG_RADIUS_RECALC) { logMessage("starting at i = 0, j = " + (count - 1)); } for (var i = 0, j = count - 1; i < j; ) { // Find first elem outside of bounds that we can move to the back while (inRange(this.boxes[i].position, this.position, this.radius) && i < j) { ++i; } // Find first elem in bounds that we can move to the front while (!inRange(this.boxes[j].position, this.position, this.radius) && i < j) { --j; ++toDelete; } if (i < j) { // swapList.push([i, j]); if (DEBUG_RADIUS_RECALC) { logMessage("swapping " + i + ", " + j); } this.boxes[i].swap(this.boxes[j]); ++i, --j; ++toDelete; } else { if (DEBUG_RADIUS_RECALC) { logMessage("terminated at i = " + i + ", j = " + j, COLORS.RED); } } } if (DEBUG_RADIUS_RECALC) { logMessage("toDelete = " + toDelete, COLORS.RED); } // Sanity check if (toDelete > this.boxes.length) { logMessage("Error: toDelete " + toDelete + " > entity count " + this.boxes.length + " (setRadius algorithm)", COLORS.RED); toDelete = this.boxes.length; } if (toDelete > 0) { // logMessage("Deleting " + toDelete + " entities as part of radius resize", COLORS.GREEN); } // Delete cleared boxes for (; toDelete > 0; --toDelete) { this.boxes.pop().destroy(); } // fix entity density (just in case -- we may have uneven entity distribution) this.updateEntityDensity(this.targetDensity); } else if (radius > this.radius) { // Grow case (much simpler) // logMessage("Setting radius to " + radius + " (grow by " + (radius - this.radius) + ")", COLORS.GREEN); // Add entities based on entity density // var density = this.getEntityDensity(); var density = this.targetDensity; var oldArea = Math.PI * this.radius * this.radius; var n = Math.floor(density * Math.PI * (radius * radius - this.radius * this.radius)); if (n > 0) { // logMessage("Adding " + n + " entities", COLORS.GREEN); // Add entities (we use a slightly different algorithm to place them in the area between two concentric circles. // This is *slightly* less uniform (the reason we're not using this everywhere is entities would be tightly clustered // at the platform center and become spread out as the radius increases), but the use-case here is just incremental // radius resizes and the user's not likely to notice the difference). for (; n > 0; --n) { var theta = Math.randRange(0.0, Math.PI * 2.0); var r = Math.randRange(this.radius, radius); // logMessage("theta = " + theta + ", r = " + r); var pos = { x: Math.cos(theta) * r + this.position.x, y: this.position.y, z: Math.sin(theta) * r + this.position.y }; // var properties = { position: pos }; // this.randomizer.randomizeShapeAndColor(properties); // this.boxes.push(new PlatformComponent(properties)); this.boxes.push(this.spawnEntity()); } } this.radius = radius; } } DynamicPlatform.prototype.updateHeight = function (height) { logMessage("Setting platform height to " + height); this.platformHeight = height; // Invalidate current boxes to trigger a rebuild this.boxes.forEach(function (box) { box.position.x += this.oldRadius * 100; }); // this.update(dt, position, radius); } /// Gets a random point within the platform bounds. /// Should maybe get moved to the RandomAttribModel (would be much cleaner), but this works for now. DynamicPlatform.prototype.randomPoint = function (position, radius) { position = position || this.position; radius = radius !== undefined ? radius : this.radius; return randomCirclePoint(radius, position); } /// Old. The RandomAttribModel replaces this and enables realtime editing of the *****_RANGE params. // DynamicPlatform.prototype.randomDimensions = function () { // return { // x: Math.randRange(WIDTH_RANGE[0], WIDTH_RANGE[1]), // y: Math.randRange(HEIGHT_RANGE[0], HEIGHT_RANGE[1]), // z: Math.randRange(DEPTH_RANGE[0], DEPTH_RANGE[1]) // }; // } // DynamicPlatform.prototype.randomColor = function () { // var shade = Math.randRange(SHADE_RANGE[0], SHADE_RANGE[1]); // // var h = HUE_RANGE; // return { // red: shade + Math.randRange(RED_RANGE[0], RED_RANGE[1]) | 0, // green: shade + Math.randRange(GREEN_RANGE[0], GREEN_RANGE[1]) | 0, // blue: shade + Math.randRange(BLUE_RANGE[0], BLUE_RANGE[1]) | 0 // } // // return COLORS[Math.randInt(COLORS.length)] // } /// Cleanup. DynamicPlatform.prototype.destroy = function () { this.boxes.forEach(function (box) { box.destroy(); }); this.boxes = []; } })(); // UI (function () { var CATCH_SETUP_ERRORS = true; // Util functions for setting up widgets (the widget library is intended to be used like this) function makePanel (dir, properties) { return new UI.WidgetStack(withDefaults(properties, { dir: dir })); } function addSpacing (parent, width, height) { parent.add(new UI.Box({ backgroundAlpha: 0.0, width: width, height: height })); } function addLabel (parent, text) { return parent.add(new UI.Label({ text: text, width: 200, height: 20 })); } function addSlider (parent, label, min, max, getValue, onValueChanged) { try { var layout = parent.add(new UI.WidgetStack({ dir: "+x" })); var textLabel = layout.add(new UI.Label({ text: label, width: 130, height: 20 })); var valueLabel = layout.add(new UI.Label({ text: "" + (+getValue().toFixed(1)), width: 60, height: 20 })); var slider = layout.add(new UI.Slider({ value: getValue(), minValue: min, maxValue: max, width: 300, height: 20, slider: { width: 30, height: 18 }, onValueChanged: function (value) { valueLabel.setText("" + (+value.toFixed(1))); onValueChanged(value, slider); UI.updateLayout(); } })); return slider; } catch (e) { logMessage("" + e, COLORS.RED); logMessage("parent: " + parent, COLORS.RED); logMessage("label: " + label, COLORS.RED); logMessage("min: " + min, COLORS.RED); logMessage("max: " + max, COLORS.RED); logMessage("getValue: " + getValue, COLORS.RED); logMessage("onValueChanged: " + onValueChanged, COLORS.RED); throw e; } } function addButton (parent, label, onClicked) { var button = parent.add(new UI.Box({ text: label, width: 160, height: 26, leftMargin: 8, topMargin: 3 })); button.addAction('onClick', onClicked); return button; } function moveToBottomLeftScreenCorner (widget) { var border = 5; var pos = { x: border, y: Controller.getViewportDimensions().y - widget.getHeight() - border }; if (widget.position.x != pos.x || widget.position.y != pos.y) { widget.setPosition(pos.x, pos.y); UI.updateLayout(); } } var _export = this; /// Setup the UI. Creates a bunch of sliders for setting the platform radius, density, and entity color / shape properties. /// The entityCount slider is readonly. function _setupUI (platform) { var layoutContainer = makePanel("+y", { visible: false }); // layoutContainer.setPosition(10, 280); // makeDraggable(layoutContainer); _export.onScreenResize = function () { moveToBottomLeftScreenCorner(layoutContainer); } var topSection = layoutContainer.add(makePanel("+x")); addSpacing(layoutContainer, 1, 5); var btmSection = layoutContainer.add(makePanel("+x")); var controls = topSection.add(makePanel("+y")); addSpacing(topSection, 20, 1); var buttons = topSection.add(makePanel("+y")); addSpacing(topSection, 20, 1); var colorControls = btmSection.add(makePanel("+y")); addSpacing(btmSection, 20, 1); var shapeControls = btmSection.add(makePanel("+y")); addSpacing(btmSection, 20, 1); // Top controls addLabel(controls, "Platform (platform.js)"); controls.radiusSlider = addSlider(controls, "radius", PLATFORM_RADIUS_RANGE[0], PLATFORM_RADIUS_RANGE[1], function () { return platform.getRadius() }, function (value) { platform.setRadiusOnNextUpdate(value); controls.entityCountSlider.setValue(platform.getEntityCountWithRadius(value)); }); addSpacing(controls, 1, 2); controls.densitySlider = addSlider(controls, "entity density", PLATFORM_DENSITY_RANGE[0], PLATFORM_DENSITY_RANGE[1], function () { return platform.getEntityDensity() }, function (value) { platform.setDensityOnNextUpdate(value); controls.entityCountSlider.setValue(platform.getEntityCountWithDensity(value)); }); addSpacing(controls, 1, 2); var minEntities = Math.PI * PLATFORM_RADIUS_RANGE[0] * PLATFORM_RADIUS_RANGE[0] * PLATFORM_DENSITY_RANGE[0]; var maxEntities = Math.PI * PLATFORM_RADIUS_RANGE[1] * PLATFORM_RADIUS_RANGE[1] * PLATFORM_DENSITY_RANGE[1]; controls.entityCountSlider = addSlider(controls, "entity count", minEntities, maxEntities, function () { return platform.getEntityCount() }, function (value) {}); controls.entityCountSlider.actions = {}; // hack: make this slider readonly (clears all attached actions) controls.entityCountSlider.slider.actions = {}; // Buttons addSpacing(buttons, 1, 22); addButton(buttons, 'rebuild', function () { platform.updateHeight(MyAvatar.position.y - AVATAR_HEIGHT_OFFSET); }); addSpacing(buttons, 1, 2); addButton(buttons, 'toggle entity type', function () { platform.toggleBoxType(); }); // Bottom controls // Iterate over controls (making sliders) for the RNG shape / dimensions model platform.randomizer.shapeModel.setupUI(function (name, value, min, max, setValue) { // logMessage("platform.randomizer.shapeModel." + name + " = " + value); var internal = { avg: (value[0] + value[1]) * 0.5, range: Math.abs(value[0] - value[1]) }; // logMessage(JSON.stringify(internal), COLORS.GREEN); addSlider(shapeControls, name + ' avg', min, max, function () { return internal.avg; }, function (value) { internal.avg = value; setValue([ internal.avg - internal.range * 0.5, internal.avg + internal.range * 0.5 ]); platform.updateEntityAttribs(); }); addSpacing(shapeControls, 1, 2); addSlider(shapeControls, name + ' range', min, max, function () { return internal.range }, function (value) { internal.range = value; setValue([ internal.avg - internal.range * 0.5, internal.avg + internal.range * 0.5 ]); platform.updateEntityAttribs(); }); addSpacing(shapeControls, 1, 2); }); // Do the same for the color model platform.randomizer.colorModel.setupUI(function (name, value, min, max, setValue) { // logMessage("platform.randomizer.colorModel." + name + " = " + value); addSlider(colorControls, name, min, max, function () { return value; }, function (value) { setValue(value); platform.updateEntityAttribs(); }); addSpacing(colorControls, 1, 2); }); moveToBottomLeftScreenCorner(layoutContainer); layoutContainer.setVisible(true); } this.setupUI = function (platform) { if (CATCH_SETUP_ERRORS) { try { _setupUI(platform); } catch (e) { logMessage("Error setting up ui: " + e, COLORS.RED); } } else { _setupUI(platform); } } })(); // Error handling w/ explicit try / catch blocks. Good for catching unexpected errors with the onscreen debugLog // (if it's enabled); bad for detailed debugging since you lose the file and line num even if the error gets rethrown. // Catch errors from init var CATCH_INIT_ERRORS = true; // Catch errors from everything (technically, Script and Controller signals that runs platform / ui code) var CATCH_ERRORS_FROM_EVENT_UPDATES = false; // Setup everything (function () { var doLater = null; if (CATCH_ERRORS_FROM_EVENT_UPDATES) { // Decorates a function w/ explicit error catching + printing to the debug log. function catchErrors (fcn) { return function () { try { fcn.apply(this, arguments); } catch (e) { logMessage('' + e, COLORS.RED); logMessage("while calling " + fcn); logMessage("Called by: " + arguments.callee.caller); } } } // We need to do this after the functions are registered... doLater = function () { // Intercept errors from functions called by Script.update and Script.ScriptEnding. [ 'teardown', 'startup', 'update', 'initPlatform', 'setupUI' ].forEach(function (fcn) { this[fcn] = catchErrors(this[fcn]); }); }; // These need to be wrapped first though: // Intercept errors from UI functions called by Controller.****Event. [ 'handleMousePress', 'handleMouseMove', 'handleMouseRelease' ].forEach(function (fcn) { UI[fcn] = catchErrors(UI[fcn]); }); } function getTargetPlatformPosition () { var pos = MyAvatar.position; pos.y -= AVATAR_HEIGHT_OFFSET; return pos; } // Program state var platform = this.platform = null; var lastHeight = null; // Init this.initPlatform = function () { platform = new DynamicPlatform(NUM_PLATFORM_ENTITIES, getTargetPlatformPosition(), RADIUS); lastHeight = getTargetPlatformPosition().y; } // Handle relative screen positioning (UI) var lastDimensions = Controller.getViewportDimensions(); function checkScreenDimensions () { var dimensions = Controller.getViewportDimensions(); if (dimensions.x != lastDimensions.x || dimensions.y != lastDimensions.y) { onScreenResize(dimensions.x, dimensions.y); } lastDimensions = dimensions; } // Update this.update = function (dt) { checkScreenDimensions(); var pos = getTargetPlatformPosition(); platform.update(dt, getTargetPlatformPosition(), platform.getRadius()); } // Teardown this.teardown = function () { try { platform.destroy(); UI.teardown(); Controller.mousePressEvent.disconnect(UI.handleMousePress); Controller.mouseMoveEvent.disconnect(UI.handleMouseMove); Controller.mouseReleaseEvent.disconnect(UI.handleMouseRelease); } catch (e) { logMessage("" + e, COLORS.RED); } } if (doLater) { doLater(); } // Delays startup until / if entities can be spawned. this.startup = function (dt) { if (Entities.canAdjustLocks() && Entities.canRez()) { Script.update.disconnect(this.startup); function init () { logMessage("initializing..."); this.initPlatform(); Script.update.connect(this.update); Script.scriptEnding.connect(this.teardown); this.setupUI(platform); logMessage("finished initializing.", COLORS.GREEN); } if (CATCH_INIT_ERRORS) { try { init(); } catch (error) { logMessage("" + error, COLORS.RED); } } else { init(); } Controller.mousePressEvent.connect(UI.handleMousePress); Controller.mouseMoveEvent.connect(UI.handleMouseMove); Controller.mouseReleaseEvent.connect(UI.handleMouseRelease); } else { if (!startup.printedWarnMsg) { startup.timer = startup.timer || startup.ENTITY_SERVER_WAIT_TIME; if ((startup.timer -= dt) < 0.0) { logMessage("Waiting for entity server"); startup.printedWarnMsg = true; } } } } startup.ENTITY_SERVER_WAIT_TIME = 0.2; // print "waiting for entity server" if more than this time has elapsed in startup() Script.update.connect(this.startup); })(); })();