'
11 | );
12 |
13 | return {
14 | isVisible: false,
15 |
16 | init: function(el) {
17 | this.$el = $(el);
18 |
19 | this.$el.on('focus', 'input', function(e) {
20 | $(this).select();
21 | });
22 | },
23 |
24 | show: function(x, y, username, timestamp) {
25 | var age = r.TimeText.now() / 1000 - timestamp;
26 | this.$el.html(template({
27 | x: x,
28 | y: y,
29 | username: username,
30 | timestamp: r.TimeText.prototype.formatTime(null, age),
31 | link: 'https://www.reddit.com/r/place#x=' + x + '&y=' + y,
32 | }));
33 | this.$el.show();
34 | this.isVisible = true;
35 | },
36 |
37 | hide: function() {
38 | this.$el.hide();
39 | this.isVisible = false;
40 | },
41 | };
42 | });
43 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/camerabutton.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('camerabutton', function(require) {
2 | var $ = require('jQuery');
3 |
4 | return {
5 | $el: null,
6 | $container: null,
7 | enabled: false,
8 |
9 | /**
10 | * Initialize the camera button.
11 | * The button is hidden by default and removed from the DOM to be
12 | * extra sneaky.
13 | * @function
14 | * @param {HTMLElement} el The button element
15 | */
16 | init: function(el) {
17 | this.$el = $(el);
18 | this.$container = this.$el.parent();
19 | this.$el.hide();
20 | this.$el.detach();
21 | },
22 |
23 | /**
24 | * Enable the button, adding it back to the DOM and making it visible
25 | * @function
26 | */
27 | enable: function() {
28 | if (this.enabled) { return; }
29 | this.enabled = true;
30 | this.$container.append(this.$el);
31 | this.$el.show();
32 | },
33 |
34 | /**
35 | * Disable the button, hiding it and removing it from the DOM
36 | */
37 | disable: function() {
38 | if (!this.enabled) { return; }
39 | this.enabled = false;
40 | this.$el.hide();
41 | this.$el.detach();
42 | },
43 |
44 | showEnable: function() {
45 | this.$el.removeClass('place-following');
46 | },
47 |
48 | showDisable: function() {
49 | this.$el.addClass('place-following');
50 | },
51 | };
52 | });
53 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/activity.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('activity', function(require) {
2 | function formatCount(count) {
3 | if (count >= 1000000) {
4 | // I don't think we need to worry about more than this :P
5 | return (count / 1000000).toFixed(2) + 'm';
6 | }
7 | if (count >= 100000) {
8 | // Drop the decimal once we're into the 100k range
9 | return parseInt((count / 1000), 10) + 'k';
10 | }
11 | if (count >= 1100) {
12 | // Show actual numbers up until we'd show at least 1.1k
13 | return (count / 1000).toFixed(1) + 'k';
14 | }
15 | return count.toString();
16 | }
17 |
18 | return {
19 | $el: null,
20 | initialized: false,
21 | lastCount: 0,
22 |
23 | /**
24 | * Initialize the counter element.
25 | * @function
26 | * @param {HTMLElement} el
27 | * @param {number} activeVisitors Count used to set the initial display
28 | */
29 | init: function(el, activeVisitors) {
30 | this.$el = $(el);
31 | this.$el.removeClass('place-uninitialized');
32 | this.initialized = true;
33 | this.setCount(activeVisitors)
34 | },
35 |
36 | /**
37 | * Update the display
38 | * @function
39 | * @param {count} count
40 | */
41 | setCount: function(count) {
42 | if (!this.initialized) { return; }
43 | if (count === this.lastCount) { return; }
44 | this.lastCount = count;
45 | this.$el.text(formatCount(count));
46 | },
47 | };
48 | });
49 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2017 reddit Inc.
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions
6 | are met:
7 | 1. Redistributions of source code must retain the above copyright
8 | notice, this list of conditions and the following disclaimer.
9 | 2. Redistributions in binary form must reproduce the above copyright
10 | notice, this list of conditions and the following disclaimer in the
11 | documentation and/or other materials provided with the distribution.
12 | 3. The name of the author or contributors may not be used to endorse or
13 | promote products derived from this software without specific prior
14 | written permission.
15 |
16 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
19 | ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
22 | OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
23 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
24 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
25 | OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
26 | SUCH DAMAGE.
27 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/camera.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('camera', function(require) {
2 | var $ = require('jQuery');
3 |
4 | // Manages camera position and zoom level.
5 | return {
6 | zoomElement: null,
7 | panElement: null,
8 | isDirty: false,
9 |
10 | /**
11 | * Initialize the camera.
12 | * @function
13 | * @param {HTMLElement} zoomElement The element to apply scale transforms on.
14 | * @param {HTMLElement} panElement The element to apply translate transforms
15 | * on. Should be a child of zoomElement!
16 | */
17 | init: function(zoomElement, panElement) {
18 | this.zoomElement = zoomElement;
19 | this.panElement = panElement;
20 | },
21 |
22 | tick: function() {
23 | if (this.isDirty) {
24 | this.isDirty = false;
25 | return true;
26 | }
27 | return false;
28 | },
29 |
30 | /**
31 | * Update the scale transform on the zoomElement element.
32 | * @function
33 | * @param {number} s The scale
34 | */
35 | updateScale: function(s) {
36 | this.isDirty = true;
37 | $(this.zoomElement).css({
38 | transform: 'scale(' + s + ',' + s + ')',
39 | });
40 | },
41 |
42 | /**
43 | * Update the translate transform on the panElement element.
44 | * @function
45 | * @param {number} x The horizontal offset
46 | * @param {number} y The vertical offset
47 | */
48 | updateTranslate: function(x, y) {
49 | this.isDirty = true;
50 | $(this.panElement).css({
51 | transform: 'translate(' + x + 'px,' + y + 'px)',
52 | });
53 | },
54 | };
55 | });
56 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/modules.js:
--------------------------------------------------------------------------------
1 | !function(r, $, _) {
2 | // A dict to store all module exports on.
3 | // Add r, $, _, store by default for convenience.
4 | var modules = {
5 | r: r,
6 | jQuery: $,
7 | underscore: _,
8 | store: store,
9 | };
10 |
11 | /**
12 | * Return the export value of the given module name.
13 | * @function
14 | * @param {string} name The name of a function that has been passed to
15 | * `r.placeModule`
16 | * @returns {any} The return value of that function, or undefined
17 | */
18 | function require(name) {
19 | return modules[name];
20 | }
21 |
22 | /**
23 | * Simple module wrapper.
24 | *
25 | * Wrap modules with this rather than the default r2 wrapper for a
26 | * *slightly* better experience.
27 | *
28 | * Call `r.placeModule` with a function, and it will call that function
29 | * with the above `require` function as the only argument. If the
30 | * module function is named, its return value is saved under that name
31 | * so that other modules can require it by name.
32 | *
33 | * This is not at all smart – dependencies must be bundled in the correct
34 | * order in __init__.py so that they are available by the time they are
35 | * required, as usual. The names of the modules match the filenames by
36 | * convention only.
37 | *
38 | * @function
39 | * @param {function} moduleFunction A function that accepts the require
40 | * function defined above as its only argument.
41 | */
42 | r.placeModule = function(name, moduleFunction) {
43 | var exportVal = moduleFunction(require);
44 | if (name) {
45 | modules[name] = exportVal;
46 | }
47 | };
48 | }(r, jQuery, _);
49 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/notifications.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('notifications', function(require) {
2 | return {
3 | DEFAULT_TIMEOUT: 3000,
4 |
5 | enabled: false,
6 |
7 | /**
8 | * Initialize
9 | * @function
10 | */
11 | init: function() {
12 | if (!window.Notification) {
13 | return;
14 | }
15 |
16 | if (Notification.permission === "granted") {
17 | this.enabled = true;
18 | return;
19 | }
20 |
21 | try {
22 | Notification.requestPermission().then(function(result) {
23 | if (result === 'granted') {
24 | this.enabled = true;
25 | }
26 | }.bind(this));
27 | } catch (err) {
28 | // Do nothing!
29 | }
30 | },
31 |
32 | disable: function() {
33 | this.enabled = false;
34 | },
35 |
36 | /**
37 | * Send a browser notification.
38 | * @function
39 | * @param {string} Text to include in the notification
40 | * @param {number} [timeout] Optional timeout. If included and > 0, the
41 | * notification will auto-dismiss after that duration (in ms). If set
42 | * to 0, the timeout will not auto-dismiss.
43 | */
44 | sendNotification: function(notificationText, timeout) {
45 | if (!this.enabled) { return }
46 |
47 | timeout = timeout === undefined ? this.DEFAULT_TIMEOUT : timeout;
48 | var notif = new Notification('Place', {
49 | icon: '/static/place_icon.png',
50 | body: notificationText,
51 | });
52 |
53 | notif.onclick = function(e) {
54 | window.focus();
55 | notif.close();
56 | };
57 |
58 | if (timeout) {
59 | setTimeout(function() {
60 | notif.close();
61 | }, timeout);
62 | }
63 | },
64 | };
65 | });
66 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/canvasevents.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('canvasevents', function(require) {
2 | var Client = require('client');
3 | var Cursor = require('cursor');
4 |
5 | // Client events that apply changes to the canvas.
6 | // IMPORTANT NOTE – (x,y) coordinates here are in "canvas space". That is,
7 | // they are relative to the top left corner of the canvas and at a 1:1 ratio
8 | // with the real canvas size. It's important to note that the Cursor object
9 | // tracks position in "container space".
10 | return {
11 | 'mouseup': function(e) {
12 | // Ignore right clicks
13 | if (e.which === 3) { return; }
14 |
15 | var x = Math.round(e.offsetX);
16 | var y = Math.round(e.offsetY);
17 |
18 | if (Cursor.didDrag) { return; }
19 |
20 | // If zoomed out, clicking will zoom in.
21 | if (!Client.isZoomedIn) {
22 | var offset = Client.getOffsetFromCameraLocation(x, y);
23 | Client.toggleZoom(offset.x, offset.y);
24 | } else if (Client.hasColor()) {
25 | Client.drawTile(x, y);
26 | } else {
27 | Client.inspectTile(x, y);
28 | }
29 | },
30 |
31 | // I.E. right-click.
32 | 'contextmenu': function(e) {
33 | // We don't actually want the OS contextual menu.
34 | e.preventDefault();
35 |
36 | // If holding a color, the right click will drop it instead of toggling
37 | // zoom levels.
38 | if (Client.hasColor()) {
39 | Client.clearColor();
40 | return;
41 | }
42 |
43 | var x = Math.round(e.offsetX);
44 | var y = Math.round(e.offsetY);
45 |
46 | // The (x, y) coordinates we have are in "canvas space" relative. We need
47 | // coordinates in "camera space", i.e. relative to the middle of the canvas.
48 | // Yes, we effectively have three coordinate systems in play.
49 | var offset = Client.getOffsetFromCameraLocation(x, y);
50 | Client.toggleZoom(offset.x, offset.y);
51 | },
52 | };
53 | });
54 |
--------------------------------------------------------------------------------
/reddit_place/lib.py:
--------------------------------------------------------------------------------
1 | import time
2 | import struct
3 |
4 | from pylons import tmpl_context as c
5 |
6 | from r2.lib import baseplate_integration
7 |
8 | from reddit_place.models import Canvas
9 | from reddit_place.models import (
10 | CANVAS_HEIGHT,
11 | CANVAS_ID,
12 | CANVAS_WIDTH,
13 | )
14 |
15 |
16 | def restore_redis_board_from_cass():
17 | """
18 | Get all pixels from cassandra and put them back into redis.
19 | """
20 | baseplate_integration.make_server_span('shell').start()
21 |
22 | # Get from cass
23 | st = time.time()
24 | canvas = Canvas.get_all()
25 | print "time to get canvas from cass: ", time.time() - st
26 |
27 | # Calculate bitmap
28 | st = time.time()
29 | bitmap = ['\x00'] * ((CANVAS_HEIGHT * CANVAS_WIDTH + 1) / 2)
30 | for (x, y), json_data in canvas.iteritems():
31 |
32 | # These shouldn't be in cassandra but are for some reason. The
33 | # frontend only displays up to 999, 999 anyway.
34 | if x > 999 or y > 999:
35 | continue
36 |
37 | color = json_data['color']
38 |
39 | # We're putting 2 integers into a single byte. If the integer is
40 | # an odd number, we can just OR it onto the byte, since we want it
41 | # at the end. If it's an even number, it needs to go at the
42 | # beginning of the byte, so we shift it first.
43 | offset = y * CANVAS_WIDTH + x
44 | if offset % 2 == 0:
45 | color = color << 4
46 |
47 | # Update the color in the bitmap. Because division rounds down, we
48 | # can simply divide by 2 to find the correct byte in the bitmap.
49 | bitmap_idx = offset / 2
50 | updated_bitmap_int = ord(bitmap[bitmap_idx]) | color
51 | packed_color = struct.pack('B', updated_bitmap_int)
52 | bitmap[bitmap_idx] = packed_color
53 | print "time to get generate canvas for redis: ", time.time() - st
54 |
55 | # Set to redis
56 | st = time.time()
57 | c.place_redis.set(CANVAS_ID, ''.join(bitmap))
58 | print "time to set canvas to redis: ", time.time() - st
59 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/palette.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('palette', function(require) {
2 | var $ = require('jQuery');
3 |
4 | // Generates the color palette UI
5 | return {
6 | SWATCH_CLASS: 'place-swatch',
7 |
8 | el: null,
9 | initialized: false,
10 |
11 | /**
12 | * Initialize the color palette UI
13 | * @function
14 | * @param {HTMLElement} el The parent element to hold the UI
15 | */
16 | init: function(el) {
17 | this.el = el;
18 | $(el).removeClass('place-uninitialized');
19 | this.initialized = true;
20 | },
21 |
22 | /**
23 | * Rebuild the color swatch elements
24 | * @function
25 | * @param {string[]} colors A list of valid css color strings
26 | */
27 | generateSwatches: function(colors) {
28 | if (!this.initialized) { return; }
29 | $(this.el).children('.' + this.SWATCH_CLASS).remove();
30 | colors.forEach(function(color, index) {
31 | this.buildSwatch(color, index);
32 | }, this);
33 | },
34 |
35 | /**
36 | * Build a color swatch element.
37 | * @function
38 | * @param {string} color A valid css color string
39 | * @returns {HTMLElement}
40 | */
41 | buildSwatch: function(color, index) {
42 | if (!this.initialized) { return; }
43 | var div = document.createElement('div');
44 | $(div)
45 | .css('backgroundColor', color)
46 | .data('color', index)
47 | .addClass('place-swatch');
48 | this.el.appendChild(div);
49 | return div;
50 | },
51 |
52 | /**
53 | * Add a highlight class to the swatch at the given index
54 | * @function
55 | * @param {number} index The index of the swatch to add the highlight to
56 | */
57 | highlightSwatch: function(index) {
58 | $(this.el).children('.place-swatch').eq(index).addClass('place-selected');
59 | },
60 |
61 | /**
62 | * Clear highlights
63 | * @function
64 | */
65 | clearSwatchHighlights: function() {
66 | $(this.el).children('.place-swatch').removeClass('place-selected');
67 | },
68 | };
69 | });
70 |
--------------------------------------------------------------------------------
/reddit_place/templates/placecanvasse.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/README.md:
--------------------------------------------------------------------------------
1 | Client-side component reference
2 | ===============================
3 |
4 | Some notes on how the various front-end modules interact with each other.
5 |
6 | If you think about the modules as all part of a directed graph, each
7 | module generally fits into one of three categories: **entrance nodes**,
8 | **internal nodes**, and **exit nodes**.
9 |
10 |
11 | ### Entrance nodes
12 |
13 | These modules listen to events from browser APIs, extract information from
14 | the event payloads, and call functions of the appropriate internal or exit
15 | components.
16 |
17 | Module | Listens to API | Requires...
18 | -----------------|----------------------|----------------------------------
19 | canvasevents | Events ($camera) | client, cursor
20 | cameraevents | Events ($container) | client, cursor
21 | mutebuttonevents | Events ($muteButton) | client
22 | paletteevents | Events ($palette) | client
23 | websocketevents | Websockets | world
24 | zoombuttonevents | Events ($zoomButton) | client
25 |
26 |
27 | ### Internal nodes
28 |
29 | Internal modules handle the business logic, manage application state, and call
30 | out to functions on the external nodes when appropriate.
31 |
32 | Module | Requires...
33 | -----------------|---------------------------------------------------------
34 | client | api, audio, camera, canvasse, hand, inspector, mutebutton, notification
35 | world | canvasse
36 | cursor | hand
37 |
38 |
39 | ### Exit nodes
40 |
41 | These modules call out to browser APIs for various reasons, e.g. to
42 | update the DOM or make requests to the backend API.
43 |
44 | Module | Calls to API
45 | -----------------|---------------------------------------------------------
46 | audio | Web Audio
47 | activity | DOM ($activityCount)
48 | camera | DOM ($container and $camera)
49 | canvasse | DOM ($canvas)
50 | hand | DOM ($hand)
51 | inspector | DOM ($inspector)
52 | mollyguard | DOM ($mollyGuard)
53 | mutebutton | DOM ($muteButton)
54 | palette | DOM ($palette)
55 | timer | DOM ($timer)
56 | zoombutton | DOM ($zoomButton)
57 | api | XHR (r2)
58 | notifications | Notification
59 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/timer.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('timer', function(require) {
2 | var $ = require('jQuery');
3 | var intervalToken;
4 |
5 | return {
6 | REFRESH_INTERVAL_MS: 100,
7 |
8 | $el: null,
9 |
10 | /**
11 | * Initialize the timer component with a DOM element
12 | * @function
13 | * @param {HTMLElement} el
14 | */
15 | init: function(el) {
16 | this.$el = $(el);
17 | this.lastDisplayText = null;
18 | },
19 |
20 | /**
21 | * Get the current time remaining from the given timestamp
22 | * @function
23 | * @param {number} stopTime A timestamp in ms, should be in the future
24 | * @returns {number} Time in ms
25 | */
26 | getTimeRemaining: function(stopTime) {
27 | var now = Date.now();
28 | return Math.max(0, stopTime - now);
29 | },
30 |
31 | show: function() {
32 | this.$el.show();
33 | },
34 |
35 | hide: function() {
36 | this.$el.hide();
37 | },
38 |
39 | setText: function(text) {
40 | this.$el.text(text);
41 | },
42 |
43 | /**
44 | * Start the timer loop
45 | * This kicks off an interval to update the UI. Note that this does
46 | * not automatically _show_ the UI.
47 | * @function
48 | * @param {number} stopTime A timestamp in ms, should be in the future
49 | */
50 | startTimer: function(stopTime) {
51 | this.stopTimer();
52 |
53 | var updateTime = function() {
54 | var ms = this.getTimeRemaining(stopTime);
55 | // Force an upper limit of 59:59 so we don't have to deal with hours :P
56 | var s = Math.min(3599, Math.ceil(ms / 1000));
57 | var m = Math.floor(s / 60);
58 | s = s % 60;
59 |
60 | var seconds = (s < 10 ? '0' : '') + s;
61 | var minutes = (m < 10 ? '0' : '') + m;
62 | var displayText = minutes + ':' + seconds;
63 |
64 | if (displayText !== this.lastDisplayText) {
65 | this.lastDisplayText = displayText;
66 | this.setText(displayText);
67 | }
68 |
69 | if (!ms) {
70 | this.stopTimer();
71 | }
72 | }.bind(this);
73 |
74 | // update immediately so the first display is correct
75 | updateTime();
76 | intervalToken = setInterval(updateTime, this.REFRESH_INTERVAL_MS);
77 | },
78 |
79 | /**
80 | * Stop the current update interval function, if there is one.
81 | * @function
82 | */
83 | stopTimer: function() {
84 | if (intervalToken) {
85 | intervalToken = clearInterval(intervalToken);
86 | }
87 | },
88 | };
89 | });
90 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/cameraevents.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('cameraevents', function(require) {
2 | var Client = require('client');
3 | var Cursor = require('cursor');
4 |
5 | var E_KEY = 69;
6 |
7 | /**
8 | * @typedef {Object} Coordinate
9 | * @property {number} x
10 | * @property {number} y
11 | */
12 |
13 | /**
14 | * Utility to get the {x, y} coordinates from an event.
15 | * @function
16 | * @param {Event} e
17 | * @returns {Coordinate}
18 | */
19 | function getCoordsFromEvent(e) {
20 | return {
21 | x: parseInt(e.clientX, 10) + window.scrollX,
22 | y: parseInt(e.clientY, 10) + window.scrollY,
23 | };
24 | }
25 |
26 | // Client events that primarily handle updating the camera
27 | // IMPORTANT NOTE – (x, y) coordinates here are in "container space". That is,
28 | // relative to the top left corner of the application container and in units
29 | // relative to the screen. These events are used to update the Cursor object,
30 | // which also tracks position in "container space".
31 | return {
32 | 'container': {
33 | 'mousedown': function(e) {
34 | var coords = getCoordsFromEvent(e);
35 | Cursor.setCursorDown(coords.x, coords.y);
36 | },
37 |
38 | 'mouseup': function(e) {
39 | var coords = getCoordsFromEvent(e);
40 | Cursor.setCursorUp(coords.x, coords.y);
41 | },
42 |
43 | 'mousemove': function(e) {
44 | var coords = getCoordsFromEvent(e);
45 |
46 | var offsetLeft = e.currentTarget ? e.currentTarget.offsetLeft : 0;
47 | var offsetTop = e.currentTarget ? e.currentTarget.offsetTop : 0;
48 | var tileCoords = Client.getLocationFromCursorPosition(
49 | coords.x - offsetLeft,
50 | coords.y - offsetTop
51 | );
52 | var activeTileCoords = Client.getCursorPositionFromLocation(tileCoords.x, tileCoords.y);
53 | Cursor.setActiveTilePosition(
54 | activeTileCoords.x + offsetLeft,
55 | activeTileCoords.y + offsetTop
56 | );
57 |
58 | if (!Cursor.isDown) {
59 | Cursor.setTargetPosition(coords.x, coords.y);
60 | return;
61 | }
62 |
63 | Client.interact();
64 |
65 | // We need to undo the previous transform first
66 | var oldOffsetX = (Cursor.x - Cursor.downX) / Client.zoom;
67 | var oldOffsetY = (Cursor.y - Cursor.downY) / Client.zoom;
68 |
69 | // Then update the cursor position so we can do the same on
70 | // the next mousemove event
71 | Cursor.setPosition(coords.x, coords.y);
72 |
73 | if (!Client.isPanEnabled) { return; }
74 |
75 | // Finally, calculate the new offset
76 | var newOffsetX = (coords.x - Cursor.downX) / Client.zoom;
77 | var newOffsetY = (coords.y - Cursor.downY) / Client.zoom;
78 |
79 | // And update the offset. Important to know that Client
80 | // expects offset coordinates in canvas-space, which is why
81 | // we are only calculating an offset relative to the current
82 | // camera position and scaling that to the zoom level.
83 | Client.setOffset(
84 | Client.panX - oldOffsetX + newOffsetX,
85 | Client.panY - oldOffsetY + newOffsetY
86 | );
87 | },
88 | },
89 |
90 | 'document': {
91 | 'keydown': function(e) {
92 | if (e.which === E_KEY) {
93 | Client.toggleZoom();
94 | }
95 | },
96 | },
97 | };
98 | });
99 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/keyboard.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('keyboard', function(require) {
2 | var KEYDOWN = 1;
3 | var KEYDOWN_PREV = 2;
4 |
5 | return {
6 | enabled: true,
7 | // The current state of keys we are tracking.
8 | // This will also act as a white list – any keys tracked here will have
9 | // their default behavior overridden while this module is active.
10 | keys: {
11 | 'UP': 0,
12 | 'DOWN': 0,
13 | 'LEFT': 0,
14 | 'RIGHT': 0,
15 | 'W': 0,
16 | 'A': 0,
17 | 'S': 0,
18 | 'D': 0,
19 | },
20 |
21 | keyNameAliases: {
22 | 'ARROWUP': 'UP',
23 | 'ARROWDOWN': 'DOWN',
24 | 'ARROWLEFT': 'LEFT',
25 | 'ARROWRIGHT': 'RIGHT',
26 | },
27 |
28 | init: function() {
29 | window.addEventListener('keydown', function (e) {
30 | if (!this.enabled) { return; }
31 | var keyName = this.getKeyName(e.keyCode, e.keyIdentifier || e.key);
32 | if (keyName in this.keys) {
33 | this.keys[keyName] |= KEYDOWN;
34 | e.preventDefault();
35 | }
36 | }.bind(this));
37 |
38 | window.addEventListener('keyup', function (e) {
39 | if (!this.enabled) { return; }
40 | var keyName = this.getKeyName(e.keyCode, e.keyIdentifier || e.key);
41 | if (keyName in this.keys) {
42 | this.keys[keyName] &= ~KEYDOWN;
43 | e.preventDefault();
44 | }
45 | }.bind(this));
46 | },
47 |
48 | /**
49 | * Disable key tracking
50 | */
51 | disable: function() {
52 | this.enabled = false;
53 | },
54 |
55 | /**
56 | * Re-enable key tracking
57 | */
58 | enable: function() {
59 | this.enabled = true;
60 | },
61 |
62 | /**
63 | * Get the keyName given keyCode and identifier from event.
64 | * @function
65 | * @param {number} keyCode
66 | * @param {string} identifier
67 | * @returns {string}
68 | */
69 | getKeyName: function(keyCode, identifier) {
70 | if (identifier.slice(0, 2) === 'U+') {
71 | identifier = String.fromCharCode(keyCode);
72 | }
73 |
74 | identifier = identifier.toUpperCase();
75 | var mappedIdentifier = this.keyNameAliases[identifier];
76 | return mappedIdentifier ? mappedIdentifier : identifier;
77 | },
78 |
79 | /**
80 | * Tick function that needs to run continuously.
81 | */
82 | tick: function() {
83 | if (!this.enabled) { return; }
84 |
85 | for (var key in this.keys) {
86 | if (this.isKeyDown(key)) {
87 | this.keys[key] |= KEYDOWN_PREV;
88 | } else {
89 | this.keys[key] &= ~KEYDOWN_PREV;
90 | }
91 | }
92 | },
93 |
94 | /**
95 | * Returns true if the given key is held down.
96 | * @param {string} keyName
97 | * @returns {boolean}
98 | */
99 | isKeyDown: function isKeyDown(keyName) {
100 | return this.keys[keyName] & KEYDOWN;
101 | },
102 |
103 | /**
104 | * Returns true if the given key was pressed this frame.
105 | * @param {string} keyName
106 | * @returns {boolean}
107 | */
108 | isKeyPressed: function isKeyPressed(keyName) {
109 | return this.keys[keyName] === KEYDOWN;
110 | },
111 |
112 | /**
113 | * Returns true if the given key was released this frame.
114 | * @param {string} keyName
115 | * @returns {boolean}
116 | */
117 | isKeyReleased: function isKeyReleased(keyName) {
118 | return this.keys[keyName] === KEYDOWN_PREV;
119 | },
120 | };
121 | });
122 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/admin/selector.js:
--------------------------------------------------------------------------------
1 | r.placeModule('selector', function(require) {
2 | // we're about to do some terrible things.
3 | var r = require('r');
4 |
5 | var AdminAPI = require('adminapi');
6 | var bindEvents = require('utils').bindEvents;
7 | var Canvasse = require('canvasse');
8 | var Client = require('client');
9 | var Hand = require('hand');
10 | var hijack = require('utils').hijack;
11 | var Palette = require('palette');
12 | var World = require('world');
13 | var Notifications = require('notifications');
14 |
15 | var Selector = {
16 | isSelecting: false,
17 | anchorX: null,
18 | anchorY: null,
19 | selectionColor: 'hotpink',
20 | };
21 |
22 | // Inject the selection functionality into the Client.drawTile method.
23 | // If in "selection" mode, we'll handle it here. Otherwise, we just
24 | // pass the call along to the original method.
25 | // WARNING - this depends on the current behavior of Client.drawTile.
26 | // If that changes significantly, this could stop working.
27 | hijack(Client, 'drawTile', function selectorDrawTile(x, y) {
28 | if (!Selector.isSelecting) {
29 | // Just normal drawing.
30 | this.targetMethod.call(this, x, y);
31 | return;
32 | }
33 |
34 | if (!this.paletteColor || !this.enabled) {
35 | return;
36 | }
37 |
38 | // Drawing the rect will take place in two steps. The first step will
39 | // be setting an anchor coordinate. The second step will define another
40 | // anchor coordinate. The rectangle will fill the space between the
41 | // two anchors.
42 |
43 | if (Selector.anchorX === null) {
44 | // If anchor coordinates aren't set, then this is step one.
45 | Selector.anchorX = x;
46 | Selector.anchorY = y;
47 | Canvasse.drawTileToDisplay(x, y, Selector.selectionColor);
48 | return;
49 | }
50 |
51 | var minX = Math.min(x, Selector.anchorX);
52 | var minY = Math.min(y, Selector.anchorY);
53 | var maxX = Math.max(x, Selector.anchorX);
54 | var maxY = Math.max(y, Selector.anchorY);
55 | var width = maxX - minX + 1;
56 | var height = maxY - minY + 1;
57 |
58 | this.disable();
59 | Canvasse.drawRectToDisplay(minX, minY, width, height, Selector.selectionColor);
60 |
61 | AdminAPI.drawRect(minX, minY, width, height).always(
62 | function onFinally() {
63 | // Undo the selection rect drawing. We'll just rely on the websocket
64 | // events for the actual updates.
65 | Canvasse.clearRectFromDisplay(minX, minY, width, height);
66 | Canvasse.drawBufferToDisplay();
67 | World.enable();
68 | this.clearColor();
69 | this.setCooldownTime(this.cooldown);
70 | }.bind(this)
71 | );
72 |
73 | Selector.isSelecting = false;
74 | Selector.anchorX = null;
75 | Selector.anchorY = null;
76 | });
77 |
78 | // Needed to make sure that Palette is initialized first.
79 | r.hooks.get('place.init').register(function() {
80 | // notifs don't make sense in admin mode since you can always place
81 | // another tile
82 | Notifications.disable();
83 |
84 | var selectionSwatch = Palette.buildSwatch(Selector.selectionColor);
85 |
86 | bindEvents(selectionSwatch, {
87 | 'click': function(e) {
88 | e.stopPropagation();
89 | Selector.isSelecting = true;
90 | // Stop drawing incoming websocket updates while selecting.
91 | World.disable();
92 | if (!Client.paletteColor) {
93 | Client.setColor(0);
94 | }
95 | Hand.updateColor(Selector.selectionColor);
96 | },
97 | });
98 | });
99 |
100 | return Selector;
101 | });
102 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/hand.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('hand', function(require) {
2 | var $ = require('jQuery');
3 |
4 | return {
5 | enabled: true,
6 | hand: null,
7 | swatch: null,
8 | visible: false,
9 |
10 | /**
11 | * Initialize the hand.
12 | * @function
13 | * @param {HTMLElement} hand The hand container, used for position
14 | * @param {HTMLElement} swatch The swatch container, used for color
15 | * @param {HTMLElement} cursor The cursor element, used to show where
16 | * the tile will be placed
17 | */
18 | init: function(hand, swatch, cursor) {
19 | this.hand = hand;
20 | this.swatch = swatch;
21 | this.cursor = cursor;
22 | },
23 |
24 | /**
25 | * Disable the hand UI. Intended for touch input support.
26 | * @function
27 | */
28 | disable: function() {
29 | if (this.visible) {
30 | this.hideCursor();
31 | }
32 | this.enabled = false;
33 | $(this.hand).css({ display: 'none' });
34 | },
35 |
36 | /**
37 | * Re-enable the hand UI
38 | * Note that this might be a little janky, since the disabled flag
39 | * currently prevents all other updates from applying. Its likely that
40 | * the color & position will be wrong until updated after re-enabling, so
41 | * if this is actually needed then it might need reworking.
42 | * @function
43 | */
44 | enable: function() {
45 | this.enabled = true;
46 | $(this.hand).css({ display: 'block' });
47 | if (this.visible) {
48 | this.showCursor();
49 | }
50 | },
51 |
52 | /**
53 | * Update the css transforms.
54 | * @function
55 | * @param {number} x The horizontal offset
56 | * @param {number} y The vertical offset
57 | * @param {number} rotateZ The amount to rotate around the z axis
58 | */
59 | updateTransform: function(x, y, rotateZ) {
60 | if (!this.enabled) { return; }
61 | $(this.hand).css({
62 | transform: 'translateX(' + x + 'px) '+
63 | 'translateY(' + y + 'px) '+
64 | 'rotateZ(' + rotateZ + 'deg)',
65 | });
66 | },
67 |
68 | /**
69 | * Update the css transforms.
70 | * @function
71 | * @param {number} x The horizontal offset
72 | * @param {number} y The vertical offset
73 | * @param {number} rotateZ The amount to rotate around the z axis
74 | */
75 | updateCursorTransform: function(x, y) {
76 | if (!this.enabled) { return; }
77 | // The -20 is hacky, but it forces this to align to the grid properly.
78 | // If the size of the pixels at max zoom level ever change, this'll need
79 | // to update.
80 | $(this.cursor).css({
81 | transform: 'translateX(' + (x - 20) + 'px) '+
82 | 'translateY(' + (y - 20) + 'px)'
83 | });
84 | },
85 |
86 | showCursor: function() {
87 | this.visible = true;
88 | if (!this.enabled) { return; }
89 | $(this.cursor).show();
90 | },
91 |
92 | hideCursor: function() {
93 | this.visible = false;
94 | if (!this.enabled) { return; }
95 | $(this.cursor).hide();
96 | },
97 |
98 | /**
99 | * Update the color displayed.
100 | * @function
101 | * @param {string} color A valid css color string
102 | */
103 | updateColor: function(color) {
104 | if (!this.enabled) { return; }
105 | $(this.swatch).css({
106 | backgroundColor: color,
107 | display: 'block',
108 | });
109 | },
110 |
111 | /**
112 | * Hide the color swatch element.
113 | * @function
114 | */
115 | clearColor: function() {
116 | if (!this.enabled) { return; }
117 | $(this.swatch).css({
118 | display: 'none',
119 | });
120 | },
121 | };
122 | });
123 |
--------------------------------------------------------------------------------
/reddit_place/__init__.py:
--------------------------------------------------------------------------------
1 | from pylons.i18n import N_
2 |
3 | from r2.config.routing import not_in_sr
4 | from r2.lib.configparse import ConfigValue
5 | from r2.lib.js import Module
6 | from r2.lib.plugin import Plugin
7 |
8 |
9 | class Place(Plugin):
10 | needs_static_build = True
11 |
12 | js = {
13 | "place-base": Module("place-base.js",
14 | # core & external dependencies
15 | "websocket.js",
16 | "place/modules.js",
17 | "place/utils.js",
18 |
19 | # 'exit node' modules, no internal dependencies
20 | "place/activity.js",
21 | "place/api.js",
22 | "place/audio.js",
23 | "place/camera.js",
24 | "place/camerabutton.js",
25 | "place/canvasse.js",
26 | "place/coordinates.js",
27 | "place/hand.js",
28 | "place/inspector.js",
29 | "place/keyboard.js",
30 | "place/mollyguard.js",
31 | "place/mutebutton.js",
32 | "place/notificationbutton.js",
33 | "place/notifications.js",
34 | "place/palette.js",
35 | "place/zoombutton.js",
36 | "place/timer.js",
37 |
38 | # 'internal node' modules, only dependant on 'exit nodes'
39 | "place/client.js",
40 | "place/cursor.js",
41 | "place/world.js",
42 |
43 | # 'entrance node' modules, only dependant on 'internal' or 'exit' nodes
44 | "place/camerabuttonevents.js",
45 | "place/cameraevents.js",
46 | "place/canvasevents.js",
47 | "place/mutebuttonevents.js",
48 | "place/notificationbuttonevents.js",
49 | "place/paletteevents.js",
50 | "place/websocketevents.js",
51 | "place/zoombuttonevents.js",
52 | ),
53 | # Optionally included admin-only modules
54 | "place-admin": Module("place-admin.js",
55 | "place/admin/api.js",
56 |
57 | "place/admin/slider.js",
58 | "place/admin/selector.js",
59 | ),
60 | "place-init": Module("place-init.js",
61 | # entry point
62 | "place/init.js",
63 | ),
64 | }
65 |
66 | config = {
67 | # TODO: your static configuratation options go here, e.g.:
68 | # ConfigValue.int: [
69 | # "place_blargs",
70 | # ],
71 | }
72 |
73 | live_config = {
74 | # TODO: your live configuratation options go here, e.g.:
75 | # ConfigValue.int: [
76 | # "place_realtime_blargs",
77 | # ],
78 | }
79 |
80 | errors = {
81 | # TODO: your API errors go here, e.g.:
82 | # "PLACE_NOT_COOL": N_("not cool"),
83 | }
84 |
85 | def add_routes(self, mc):
86 | mc("/place", controller="place", action="canvasse",
87 | conditions={"function": not_in_sr}, is_embed=False)
88 | mc("/place/embed", controller="place", action="canvasse",
89 | conditions={"function": not_in_sr}, is_embed=True)
90 | mc("/api/place/time", controller="place", action="time_to_wait",
91 | conditions={"function": not_in_sr})
92 | mc("/api/place/board-bitmap", controller="loggedoutplace",
93 | action="board_bitmap", conditions={"function": not_in_sr})
94 |
95 | mc("/api/place/:action", controller="place",
96 | conditions={"function": not_in_sr})
97 |
98 | def load_controllers(self):
99 | from r2.lib.pages import Reddit
100 | from reddit_place.controllers import (
101 | controller_hooks,
102 | PlaceController,
103 | )
104 |
105 | controller_hooks.register_all()
106 |
107 | Reddit.extra_stylesheets.append('place_global.less')
108 |
109 | def declare_queues(self, queues):
110 | # TODO: add any queues / bindings you need here, e.g.:
111 | #
112 | # queues.some_queue_defined_elsewhere << "routing_key"
113 | #
114 | # or
115 | #
116 | # from r2.config.queues import MessageQueue
117 | # queues.declare({
118 | # "some_q": MessageQueue(),
119 | # })
120 | pass
121 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/utils.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('utils', function(require) {
2 | var MIN_LERP_VAL = 0.05;
3 |
4 | return {
5 | /**
6 | * Utility for linear interpolation between to values
7 | * Useful as a cheap and easy way to ease between two values
8 | *
9 | * lerp(0, 10, .5);
10 | * // 5
11 | *
12 | * @function
13 | * @param {number} startVal The current value
14 | * @param {number} endVal The target value
15 | * @param {number} interpolationAmount A float between 0 and 1, usually
16 | * amount of passed time * some interpolation speed
17 | * @returns {number} The interpolated value
18 | */
19 | lerp: function(startVal, endVal, interpolationAmount) {
20 | var lerpVal = startVal + interpolationAmount * (endVal - startVal);
21 | if (Math.abs(endVal - lerpVal) < MIN_LERP_VAL) {
22 | return endVal;
23 | }
24 | return lerpVal;
25 | },
26 |
27 | /**
28 | * Utility for binding a bunch of events to a single element.
29 | * @function
30 | * @param {HTMLElement} target
31 | * @param {Object} eventsDict A dictionary of event handling functions.
32 | * Each key should be the name of the event to bind the handler to.
33 | * @param {bool} [useCapture] Whether to use event capturing. Defaults to true.
34 | */
35 | bindEvents: function(target, eventsDict, useCapture) {
36 | useCapture = useCapture === undefined ? true : useCapture;
37 |
38 | for (var event in eventsDict) {
39 | // If useCapture changes from true to false,
40 | // CanvasEvents.mouseup will stop working correctly
41 | target.addEventListener(event, eventsDict[event], true);
42 | }
43 | },
44 |
45 | /**
46 | * Utility to parse a hex color string into a color object
47 | * @function
48 | * @param {string} hexColor A css hex color, including the # prefix
49 | * @returns {Color}
50 | */
51 | parseHexColor: function(hexColor) {
52 | var colorVal = parseInt(hexColor.slice(1), 16);
53 | return {
54 | red: colorVal >> 16 & 0xFF,
55 | green: colorVal >> 8 & 0xFF,
56 | blue: colorVal & 0xFF,
57 | };
58 | },
59 |
60 | /**
61 | * Utility for wrapping a method with a sort of decorator function
62 | * Used specifically from admin tools to inject behavior into some other modules
63 | * @function
64 | * @param {Object} target
65 | * @param {string} methodName
66 | * @param {function} fn
67 | */
68 | hijack: function(target, methodName, fn) {
69 | var targetMethod = target[methodName];
70 | // Overwrite the original function. The fn function can access
71 | // the original function as this.targetMethod
72 | target[methodName] = function() {
73 | // Give the context object a special key that points to the original function
74 | target.targetMethod = targetMethod;
75 | var res = fn.apply(target, arguments);
76 | delete target.targetMethod;
77 | };
78 | },
79 |
80 | /**
81 | * Keeps a value between a min and a max value
82 | * @function
83 | * @param {number} min
84 | * @param {number} max
85 | * @param {number} num The value you're trying to clamp
86 | * @returns {number} The clamped value
87 | */
88 | clamp: function(min, max, num) {
89 | return Math.min(Math.max(num, min), max);
90 | },
91 |
92 | /**
93 | * Get the distance between two coordinates.
94 | * @param {number} x1
95 | * @param {number} y1
96 | * @param {number} x2
97 | * @param {number} y2
98 | * @returns {number}
99 | */
100 | getDistance: function(x1, y1, x2, y2) {
101 | var dx = x1 - x2;
102 | var dy = y1 - y2;
103 | return Math.sqrt(dx * dx + dy * dy);
104 | },
105 |
106 | /**
107 | * Normalizes a given {x, y} vector to be a unit-length vector
108 | * This modifies the given vector object.
109 | * @param {Object} vector
110 | */
111 | normalizeVector: function(vector) {
112 | var x = vector.x;
113 | var y = vector.y;
114 | if (!(x || y)) { return }
115 | var length = Math.sqrt(x * x + y * y);
116 | if (!length) { return }
117 | vector.x = x / length;
118 | vector.y = y / length;
119 | },
120 | };
121 | });
122 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/cursor.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('cursor', function(require) {
2 | var Hand = require('hand');
3 | var lerp = require('utils').lerp;
4 |
5 | /**
6 | * Utility for getting the length of a vector
7 | *
8 | * vectorLength(2, 3);
9 | * // 3.605551275463989
10 | *
11 | * @function
12 | * @param {number} x Vector x component
13 | * @param {number} y Vector y component
14 | * @returns {number} The length of the vector
15 | */
16 | function vectorLength(x, y) {
17 | return Math.sqrt(x * x + y * y);
18 | }
19 |
20 | // State tracking for input.
21 | return {
22 | MIN_DRAG_DISTANCE: 2,
23 | MIN_CURSOR_LERP_DELTA: 1,
24 | ROTATE_Z_FACTOR: 6,
25 | CURSOR_TRANSLATE_LERP: 1,
26 | CURSOR_ROTATE_LERP: .5,
27 |
28 | isUsingTouch: false,
29 | isDown: false,
30 | downX: 0,
31 | downY: 0,
32 | upX: 0,
33 | upY: 0,
34 | x: 0,
35 | y: 0,
36 | rotateZ: 0,
37 | dragDistance: 0,
38 | didDrag: false,
39 | // For values that can be 'lerp'ed, copies of the attribute
40 | // prefixed with an underscore (e.g. _zoom) are used to track
41 | // the current *actual* value, while the unprefixed attribute
42 | // tracks the *target* value.
43 | _x: 0,
44 | _y: 0,
45 | _rotateZ: 0,
46 |
47 | tick: function() {
48 | var transformUpdated = false;
49 |
50 | if (this._x !== this.x) {
51 | transformUpdated = true;
52 | var deltaX = this.x - this._x;
53 | var absDeltaX = Math.abs(deltaX);
54 | this._x = lerp(this._x, this.x, this.CURSOR_TRANSLATE_LERP);
55 | if (absDeltaX < this.MIN_CURSOR_LERP_DELTA) {
56 | this._x = this.x;
57 | }
58 |
59 | // The target value for rotateZ is set by the current horizontal movement speed.
60 | var rotateZDirection = deltaX > 0 ? -1 : 1;
61 | this.rotateZ = Math.log2(absDeltaX) * rotateZDirection * this.ROTATE_Z_FACTOR;
62 | if (!isFinite(this.rotateZ) || absDeltaX < this.MIN_CURSOR_LERP_DELTA) {
63 | this.rotateZ = 0;
64 | }
65 | } else if (this.rotateZ) {
66 | // If we've stopped moving horizontally, set the rotateZ target to 0.
67 | this.rotateZ = 0;
68 | }
69 |
70 | if (this._y !== this.y) {
71 | transformUpdated = true;
72 | var deltaY = this.y - this._y;
73 | this._y = lerp(this._y, this.y, this.CURSOR_TRANSLATE_LERP);
74 | if (Math.abs(deltaY) < this.MIN_CURSOR_LERP_DELTA) {
75 | this._y = this.y;
76 | }
77 | }
78 |
79 | if (this._rotateZ !== this.rotateZ) {
80 | transformUpdated = true;
81 | var deltaRotateZ = this.rotateZ - this._rotateZ;
82 | this._rotateZ = lerp(this._rotateZ, this.rotateZ, this.CURSOR_ROTATE_LERP);
83 | if (Math.abs(deltaRotateZ) < this.MIN_CURSOR_LERP_DELTA) {
84 | this._rotateZ = this.rotateZ;
85 | }
86 | }
87 |
88 | if (transformUpdated) {
89 | Hand.updateTransform(this._x, this._y, this._rotateZ);
90 | }
91 | },
92 |
93 | /**
94 | * Set the cursor state to down.
95 | * @function
96 | * @param {number} x
97 | * @param {number} y
98 | */
99 | setCursorDown: function(x, y) {
100 | if (this.isDown) { return; }
101 |
102 | this.isDown = true;
103 | this.downX = x;
104 | this.downY = y;
105 | this.setPosition(x, y);
106 | this.didDrag = false;
107 | },
108 |
109 | /**
110 | * Set the cursor state to up.
111 | * @function
112 | * @param {number} x
113 | * @param {number} y
114 | */
115 | setCursorUp: function(x, y) {
116 | if (!this.isDown) { return; }
117 |
118 | this.isDown = false;
119 | this.upX = x;
120 | this.upY = y;
121 | this.setPosition(x, y);
122 | this.dragDistance = vectorLength(this.upX - this.downX, this.upY - this.downY);
123 | this.didDrag = (this.dragDistance >= this.MIN_DRAG_DISTANCE);
124 | },
125 |
126 | /**
127 | * Set the current cursor position.
128 | * @function
129 | * @param {number} x
130 | * @param {number} y
131 | */
132 | setPosition: function(x, y) {
133 | this._x = this.x = x;
134 | this._y = this.y = y;
135 | Hand.updateTransform(x, y, 0);
136 | },
137 |
138 | /**
139 | * Update the target position for lerping
140 | * @function
141 | * @param {number} x
142 | * @param {number} y
143 | */
144 | setTargetPosition: function(x, y) {
145 | this.x = x;
146 | this.y = y;
147 | },
148 |
149 | setActiveTilePosition: function(x, y) {
150 | Hand.updateCursorTransform(x, y);
151 | },
152 |
153 | /**
154 | * Update whether or not we're using touch events
155 | * @function
156 | * @param {boolean} isUsingTouch
157 | */
158 | setTouchMode: function(isUsingTouch) {
159 | this.isUsingTouch = isUsingTouch;
160 | if (isUsingTouch) {
161 | Hand.disable();
162 | } else {
163 | Hand.enable();
164 | }
165 | },
166 | };
167 | });
168 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/api.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('api', function(require) {
2 | var r = require('r');
3 | var buildFullURL = function(url) {
4 | if (window.location.hostname == "oauth.reddit.com") {
5 | return window.location.protocol + "//www.reddit.com" + url;
6 | }
7 | return window.location.protocol + "//" + window.location.hostname + url;
8 | };
9 |
10 | var buildOauthUrl = function(relativeUrl) {
11 | if (injectedHeaders['Authorization']) {
12 | relativeUrl = 'https://oauth.reddit.com' + relativeUrl;
13 | }
14 | return relativeUrl;
15 | };
16 |
17 | var injectedHeaders = {};
18 |
19 | // Collection of functions that call out to the backend API.
20 | // All requests made to the banckend from the client are defined here.
21 | return {
22 | injectHeaders: function(headers) {
23 | injectedHeaders = headers;
24 | },
25 |
26 | /**
27 | * POST to the draw API
28 | * @function
29 | * @param {int} x,
30 | * @param {int} y,
31 | * @param {int} color index
32 | * @returns {Promise}
33 | */
34 | draw: function(x, y, color) {
35 | return r.ajax({
36 | url: buildOauthUrl('/api/place/draw.json'),
37 | type: 'POST',
38 | headers: injectedHeaders,
39 | data: {
40 | x: x,
41 | y: y,
42 | color: color,
43 | },
44 | });
45 | },
46 |
47 | /**
48 | * GET a bitmap representation of the board state
49 | * @function
50 | * @returns {Promise}
51 | */
52 | getCanvasBitmapState: function() {
53 | var dfd = $.Deferred();
54 |
55 | var timestamp;
56 | var canvas = new Uint8Array(r.config.place_canvas_width * r.config.place_canvas_height);
57 | var offset = 0;
58 |
59 | /**
60 | * Handle a single "chunk" or response data.
61 | * This modifies the local timestamp, canvas, and offset variables.
62 | * @function
63 | * @param {Uint8Array} responseArray
64 | */
65 | function handleChunk(responseArray) {
66 | // If we haven't set the timestamp yet, slice it off of this chunk
67 | if (!timestamp) {
68 | timestamp = (new Uint32Array(responseArray.buffer, 0, 1))[0],
69 | responseArray = new Uint8Array(responseArray.buffer, 4);
70 | }
71 | // Each byte in the responseArray represents two values in the canvas
72 | for (var i = 0; i < responseArray.byteLength; i++) {
73 | canvas[offset + 2 * i] = responseArray[i] >> 4;
74 | canvas[offset + 2 * i + 1] = responseArray[i] & 15;
75 | }
76 | offset += responseArray.byteLength * 2;
77 | }
78 |
79 | if (window.fetch) {
80 | // If the fetch API is available, use it so we can process the response
81 | // in chunks as it comes in.
82 | // TODO - should we render the board as it streams in?
83 | fetch(buildFullURL("/api/place/board-bitmap"), { credentials: 'include' })
84 | .then(function(res) {
85 | // Firefox implements the fetch API, but doesn't support the
86 | // ReadableStream portion that Chrome does. In that case we'll
87 | // use the arrayBuffer method, which reads the response to
88 | // completion and returns a Promise
89 | if (!(res.body && res.body.getReader)) {
90 | res.arrayBuffer().then(function(arrayBuffer) {
91 | handleChunk(new Uint8Array(arrayBuffer));
92 | dfd.resolve(timestamp, canvas);
93 | });
94 | return;
95 | }
96 |
97 | function next(reader) {
98 | reader.read().then(function(chunk) {
99 | if (chunk.done) {
100 | dfd.resolve(timestamp, canvas);
101 | } else {
102 | handleChunk(chunk.value);
103 | next(reader);
104 | }
105 | });
106 | }
107 | next(res.body.getReader());
108 | });
109 | } else {
110 | // Fall back to using a normal XHR request.
111 | var oReq = new XMLHttpRequest();
112 | oReq.responseType = "arraybuffer";
113 | var resp = oReq.open("GET", buildFullURL("/api/place/board-bitmap"), true);
114 |
115 | oReq.onload = function (oEvent) {
116 | var arrayBuffer = oReq.response;
117 | if (!arrayBuffer) { dfd.resolve(); }
118 | var responseArray = new Uint8Array(arrayBuffer);
119 | handleChunk(responseArray);
120 | dfd.resolve(timestamp, canvas);
121 | };
122 |
123 | oReq.send(null);
124 | }
125 |
126 | return dfd.promise();
127 | },
128 |
129 | /**
130 | * GET the amount of time remaining on the current user's cooldown.
131 | * @function
132 | * @returns {Promise}
133 | */
134 | getTimeToWait: function() {
135 | return r.ajax({
136 | url: buildOauthUrl('/api/place/time.json'),
137 | headers: injectedHeaders,
138 | type: 'GET',
139 | }).then(function onSuccess(responseJSON, status, jqXHR) {
140 | return 1000 * responseJSON.wait_seconds
141 | });
142 | },
143 |
144 | /**
145 | * Get info about the current pixel.
146 | * @function
147 | * @param {int} x,
148 | * @param {int} y,
149 | * @returns {Promise}
150 | */
151 | getPixelInfo: function(x, y) {
152 | return r.ajax({
153 | url: buildOauthUrl('/api/place/pixel.json'),
154 | headers: injectedHeaders,
155 | type: 'GET',
156 | data: {
157 | x: x,
158 | y: y,
159 | },
160 | });
161 | },
162 | };
163 | });
164 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/canvasse.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('canvasse', function(require) {
2 | /**
3 | * A dict with red, green, and blue color values. Each color
4 | * is an int between 0 and 255.
5 | * @typedef {Object} Color
6 | * @property {number} red
7 | * @property {number} green
8 | * @property {number} blue
9 | */
10 |
11 | var $ = require('jQuery');
12 |
13 | // Model the state of the canvas
14 | // This is really just a thin wrapper around the native canvas API.
15 | return {
16 | width: 0,
17 | height: 0,
18 | el: null,
19 | ctx: null,
20 | // TODO - not sure if I'll actually need these yet, remove if they aren't
21 | // getting used.
22 | // Flags to let us know when the two canvases are out of sync
23 | isBufferDirty: false,
24 | isDisplayDirty: false,
25 |
26 | /**
27 | * Initialize the Canvasse
28 | * @function
29 | * @param {HTMLCavasElement} el The canvas element to draw into
30 | * @param {number} width
31 | * @param {number} height
32 | */
33 | init: function(el, width, height) {
34 | this.width = width;
35 | this.height = height;
36 |
37 | // The canvas state as visible to the user
38 | this.el = el;
39 | this.el.width = width;
40 | this.el.height = height;
41 | this.ctx = this.el.getContext('2d');
42 | this.ctx.mozImageSmoothingEnabled = false;
43 | this.ctx.webkitImageSmoothingEnabled = false;
44 | this.ctx.msImageSmoothingEnabled = false;
45 | this.ctx.imageSmoothingEnabled = false;
46 |
47 | // This array buffer will hold color data to be drawn to the canvas.
48 | this.buffer = new ArrayBuffer(width * height * 4);
49 | // This view into the buffer is used to construct the PixelData object
50 | // for drawing to the canvas
51 | this.readBuffer = new Uint8ClampedArray(this.buffer);
52 | // This view into the buffer is used to write. Values written should be
53 | // 32 bit colors stored as AGBR (rgba in reverse).
54 | this.writeBuffer = new Uint32Array(this.buffer);
55 | },
56 |
57 | /**
58 | * Tick function that draws buffered updates to the display.
59 | * @function
60 | * @returns {boolean} Returns true if any updates were made
61 | */
62 | tick: function() {
63 | if (this.isBufferDirty) {
64 | this.drawBufferToDisplay();
65 | return true;
66 | }
67 | return false;
68 | },
69 |
70 | /**
71 | * Draw a color to the buffer canvas and immediately update.
72 | * Coordinates are in canvas pixels, not screen pixels.
73 | * @deprecated Use drawTileToDisplay or drawTileToBuffer
74 | * @function
75 | * @param {int} x
76 | * @param {int} y
77 | * @param {number} color AGBR color number
78 | */
79 | drawTileAt: function(x, y, color) {
80 | this.drawTileToBuffer(x, y, color);
81 | },
82 |
83 | /**
84 | * Draw a color to the display canvas
85 | * Used for optimistic updates or temporary drawing for UI purposes.
86 | * Updates will be lost if drawBufferToDisplay is called.
87 | * @function
88 | * @param {int} x
89 | * @param {int} y
90 | * @param {string} color Any valid css color string
91 | */
92 | drawTileToDisplay: function(x, y, color) {
93 | this.ctx.fillStyle = color;
94 | this.ctx.fillRect(x, y, 1, 1);
95 | this.isDisplayDirty = true;
96 | },
97 |
98 | /**
99 | * Fill a rectangle on the display canvas with the given color.
100 | * @function
101 | * @param {int} x
102 | * @param {int} y
103 | * @param {int} width
104 | * @param {int} height
105 | * @param {string} color Any valid css color string
106 | */
107 | drawRectToDisplay: function(x, y, width, height, color) {
108 | this.ctx.fillStyle = color;
109 | this.ctx.fillRect(x, y, width, height);
110 | this.isDisplayDirty = true;
111 | },
112 |
113 | /**
114 | * Fill a rectangle on the display canvas with the given color.
115 | * @function
116 | * @param {int} x
117 | * @param {int} y
118 | * @param {int} width
119 | * @param {int} height
120 | */
121 | clearRectFromDisplay: function(x, y, width, height) {
122 | this.ctx.clearRect(x, y, width, height);
123 | this.isDisplayDirty = true;
124 | },
125 |
126 | /**
127 | * Draw a color to the buffer canvas
128 | * Does not update the display canvas. Call drawBufferToDisplay to copy
129 | * buffered updates to the display.
130 | * @function
131 | * @param {int} x
132 | * @param {int} y
133 | * @param {number} color AGBR color
134 | */
135 | drawTileToBuffer: function(x, y, color) {
136 | var i = this.getIndexFromCoords(x, y);
137 | this.setBufferState(i, color);
138 | },
139 |
140 | /**
141 | * Get the flat-array index of the tile at the given coordinates
142 | * @function
143 | * @param {int} x
144 | * @param {int} y
145 | * @returns {int}
146 | */
147 | getIndexFromCoords: function(x, y) {
148 | return y * this.width + x;
149 | },
150 |
151 | /**
152 | * Draw a color to the buffer canvas
153 | * Does not update the display canvas. Call drawBufferToDisplay to copy
154 | * buffered updates to the display.
155 | * @function
156 | * @param {int} i
157 | * @param {number} color AGBR color
158 | */
159 | setBufferState: function(i, color) {
160 | this.writeBuffer[i] = color;
161 | this.isBufferDirty = true;
162 | },
163 |
164 | /**
165 | * Update the display canvas by drawing from the buffered canvas
166 | * @function
167 | */
168 | drawBufferToDisplay: function() {
169 | var imageData = new ImageData(this.readBuffer, this.width, this.height);
170 | this.ctx.putImageData(imageData, 0, 0);
171 | this.isBufferDirty = false;
172 | },
173 | };
174 | });
175 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/audio.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('audio', function(require) {
2 | /**
3 | * Utility for getting the frequency of a note.
4 | * The formula can be found here http://www.phy.mtu.edu/~suits/NoteFreqCalcs.html
5 | * Uses the A440 pitch standard, with A4 is defined as the 69th key (as it is in MIDI).
6 | * @function
7 | * @param {number} key A number representing a key on the piano scale. A value of
8 | * 1 represents a half-step on the scale.
9 | * @returns {number} The frequency (Hz) of the note.
10 | */
11 | function getKeyFrenquency(key) {
12 | return Math.pow(2, (key - 69) / 12) * 440;
13 | }
14 |
15 | // We're going to create a map of human-readable key indicies to
16 | // frequencies, so that we don't need to constantly calculate them *and*
17 | // so they are a little more friendly to use (i.e. 'C4' is easier to
18 | // think about as 'middle C' than the number 60 or the frequency 261.63)
19 |
20 | // Note letters. Each note's number will be its index in this array;
21 | var NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
22 |
23 | // Aliases so we can use 'flat' notes if needed.
24 | var NOTE_ALIASES = {
25 | 'C#': 'D@',
26 | 'D#': 'E@',
27 | 'F#': 'G@',
28 | 'G#': 'A@',
29 | 'A#': 'B@',
30 | }
31 |
32 | // The number of octaves to map out in our Key map.
33 | var OCTAVES = 8;
34 |
35 | // Map of human-readable notes to key frequencies.
36 | // e.g. Keys['C4'] === 261.63
37 | var Keys = {};
38 |
39 | NOTES.forEach(function(note, noteIndex) {
40 | var i, key, freq;
41 | for (i = 0; i < OCTAVES; i++) {
42 | // The key number will be the octave number * 12 + the index of the current note.
43 | // We're offsetting the octave number by 1 so that middle C (key number 60) ends up being 'C4'.
44 | key = (i + 1) * 12 + noteIndex;
45 | freq = getKeyFrenquency(key);
46 | Keys[note + i] = freq;
47 |
48 | // If this note has an alias, add it to the map as well.
49 | if (note in NOTE_ALIASES) {
50 | Keys[NOTE_ALIASES[note] + i] = freq;
51 | }
52 | }
53 | });
54 |
55 | // Audio controller. Must be initialized before use!
56 | return {
57 | audioCtx: null,
58 | audioGain: null,
59 | enabled: true,
60 | isSupported: true,
61 |
62 | init: function() {
63 | var AudioContext = window.AudioContext // Default
64 | || window.webkitAudioContext; // Safari and old versions of Chrome
65 |
66 | if (AudioContext) {
67 | this.audioCtx = new AudioContext();
68 | this.audioGain = this.audioCtx.createGain();
69 | this.audioGain.connect(this.audioCtx.destination);
70 | } else {
71 | this.enabled = false;
72 | this.isSupported = false;
73 | }
74 | },
75 |
76 | /**
77 | * Disable sound effects.
78 | * @function
79 | */
80 | disable: function() {
81 | this.enabled = false;
82 | },
83 |
84 | /**
85 | * Re-enable sound effects
86 | * @function
87 | */
88 | enable: function() {
89 | this.enabled = true;
90 | },
91 |
92 | /**
93 | * Schedules a frequency to be played between the given times.
94 | * Times are in seconds, relative to when the audio context was initialized.
95 | * @function
96 | * @param {number} frequency The frequency (Hz) of the note to play
97 | * @param {number} startTime Time (in seconds from initialization) to start playing
98 | * @param {number} stopTime Time (in seconds from initialization) to stop playing
99 | */
100 | scheduleAudio: function(frequency, startTime, stopTime) {
101 | var o = this.audioCtx.createOscillator();
102 | o.frequency.value = frequency;
103 | o.connect(this.audioGain);
104 | o.start(startTime);
105 | o.stop(stopTime);
106 | },
107 |
108 | /**
109 | * @typedef {Array} ClipNote
110 | * @property {number} 0 The frequency of the note
111 | * @property {number} 1 The duration of the note, in seconds
112 | */
113 |
114 | /**
115 | * Play a compiled audio clip right now.
116 | * @function
117 | * @param {ClipNote[]} audioClip A 2d array, where the inner arrays
118 | * each contain a frequency as the first item and a duration (in seconds)
119 | * as the second.
120 | * @param {number} [volume] optionally sets the volume. Should be a float
121 | * between 0 (muted) and 1 (full volume). Defaults to globalVolume.
122 | */
123 | playClip: function(audioClip, volume) {
124 | if (!this.enabled) { return }
125 |
126 | volume = volume === undefined ? this.globalVolume : Math.max(0, Math.min(1, volume));
127 |
128 | this.audioGain.gain.value = volume;
129 |
130 | var currentTime = this.audioCtx.currentTime;
131 |
132 | var clipNote, frequency, duration;
133 | for (var i = 0; i < audioClip.length; i++) {
134 | clipNote = audioClip[i];
135 | frequency = clipNote[0];
136 | duration = clipNote[1];
137 |
138 | // Allows for defining rest notes as frequency 0;
139 | if (frequency) {
140 | this.scheduleAudio(frequency, currentTime, currentTime + duration);
141 | }
142 |
143 | currentTime += duration;
144 | }
145 | },
146 |
147 | /**
148 | * @typedef {Array} NoteDef
149 | * @property {string} 0 The human-readable key, e.g. "C4" or "B#5"
150 | * @property {number} 1 The duration of the note, in seconds
151 | */
152 |
153 | /**
154 | * Compile a list of human-readable notes for playback with AudioManager.playClip
155 | *
156 | * var mySoundClip = AudioManager.compileClip([
157 | * ['E4', 1/8], ['D4', 1/8], ['C4', 1/8], ['D4', 1/8], ['E4', 1/2],
158 | * ]);
159 | * AudioManager.playClip(mySoundClip);
160 | *
161 | * @function
162 | * @param {NoteDef[]}
163 | * @returns {ClipNote[]}
164 | */
165 | compileClip: function(noteList) {
166 | return noteList.map(function(noteDef) {
167 | var key = noteDef[0];
168 | var duration = noteDef[1];
169 | var frequency = Keys[key] || 0;
170 | return [frequency, duration];
171 | });
172 | },
173 |
174 | /**
175 | * Set the default volume for all clips played via playClip.
176 | * @function
177 | * @param {number} volume sets globalVolume. Should be a float
178 | * between 0 (muted) and 1 (full volume)
179 | */
180 | setGlobalVolume: function(volume) {
181 | this.globalVolume = Math.min(1, Math.max(0, volume));
182 | },
183 | };
184 | });
185 |
--------------------------------------------------------------------------------
/reddit_place/models.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | import json
3 | import struct
4 | import time
5 |
6 | from pycassa.system_manager import TIME_UUID_TYPE, INT_TYPE
7 | from pycassa.types import CompositeType, IntegerType
8 | from pycassa.util import convert_uuid_to_time
9 | from pylons import app_globals as g
10 | from pylons import tmpl_context as c
11 |
12 | from r2.lib.db import tdb_cassandra
13 |
14 | CANVAS_ID = "real_1"
15 | CANVAS_WIDTH = 1000
16 | CANVAS_HEIGHT = 1000
17 |
18 |
19 | class RedisCanvas(object):
20 |
21 | @classmethod
22 | def get_board(cls):
23 | # We plan on heavily caching this board bitmap. We include the
24 | # timestamp as a 32 bit uint at the beginning so the client can make a
25 | # determination as to whether the cached state is too old. If it's too
26 | # old, the client will hit the non-fastly-cached endpoint directly.
27 | timestamp = time.time()
28 | # If no pixels have been placed yet, we'll get back None. This will
29 | # cause concatenation to fail below, so we turn it into a string
30 | # instead.
31 | bitmap = c.place_redis.get(CANVAS_ID) or ''
32 | return struct.pack('I', int(timestamp)) + bitmap
33 |
34 | @classmethod
35 | def set_pixel(cls, color, x, y):
36 | # The canvas is stored in one long redis bitfield, offset by the
37 | # coordinates of the pixel. For instance, for a canvas of width 1000,
38 | # the offset for position (1, 1) would be 1001. redis conveniently
39 | # lets us ignore our integer size when specifying our offset, doing the
40 | # calculation for us. For instance, rather than (3, 0) being sent as
41 | # offset 72 for a 24-bit integer, we can just use the offset 3.
42 | #
43 | # https://redis.io/commands/bitfield
44 | #
45 | UINT_SIZE = 'u4' # Max value: 15
46 | offset = y * CANVAS_WIDTH + x
47 | c.place_redis.execute_command(
48 | 'bitfield', CANVAS_ID, 'SET',
49 | UINT_SIZE, '#%d' % offset, color)
50 |
51 |
52 | class Pixel(tdb_cassandra.UuidThing):
53 | _use_db = True
54 | _connection_pool = 'main'
55 |
56 | _read_consistency_level = tdb_cassandra.CL.QUORUM
57 | _write_consistency_level = tdb_cassandra.CL.QUORUM
58 |
59 | _int_props = (
60 | 'x',
61 | 'y',
62 | )
63 |
64 | @classmethod
65 | def create(cls, user, color, x, y):
66 |
67 | # We dual-write to cassandra to allow the frontend to get information
68 | # on a particular pixel, as well as to have a backup, persistent state
69 | # of the board in case something goes wrong with redis.
70 | pixel = cls(
71 | canvas_id=CANVAS_ID,
72 | user_name=user.name if user else '',
73 | user_fullname=user._fullname if user else '',
74 | color=color,
75 | x=x,
76 | y=y,
77 | )
78 | pixel._commit()
79 |
80 | Canvas.insert_pixel(pixel)
81 |
82 | if user:
83 | PixelsByParticipant.add(user, pixel)
84 |
85 | RedisCanvas.set_pixel(color, x, y)
86 |
87 | g.stats.simple_event('place.pixel.create')
88 |
89 | return pixel
90 |
91 | @classmethod
92 | def get_last_placement_datetime(cls, user):
93 | return PixelsByParticipant.get_last_pixel_datetime(user)
94 |
95 | @classmethod
96 | def get_pixel_at(cls, x, y):
97 | pixel_dict = Canvas.get(x, y)
98 | if not pixel_dict:
99 | return None
100 |
101 | return dict(
102 | user_name=pixel_dict["user_name"],
103 | color=pixel_dict["color"],
104 | x=x,
105 | y=y,
106 | timestamp=pixel_dict["timestamp"],
107 | )
108 |
109 |
110 | class PixelsByParticipant(tdb_cassandra.View):
111 | _use_db = True
112 | _connection_pool = 'main'
113 |
114 | _compare_with = TIME_UUID_TYPE
115 | _read_consistency_level = tdb_cassandra.CL.QUORUM
116 | _write_consistency_level = tdb_cassandra.CL.QUORUM
117 |
118 | @classmethod
119 | def _rowkey(cls, user):
120 | return CANVAS_ID + "_ " + user._fullname
121 |
122 | @classmethod
123 | def add(cls, user, pixel):
124 | rowkey = cls._rowkey(user)
125 | pixel_dict = {
126 | "user_fullname": pixel.user_fullname,
127 | "color": pixel.color,
128 | "x": pixel.x,
129 | "y": pixel.y,
130 | }
131 | columns = {pixel._id: json.dumps(pixel_dict)}
132 | cls._cf.insert(rowkey, columns)
133 |
134 | @classmethod
135 | def get_last_pixel_datetime(cls, user):
136 | rowkey = cls._rowkey(user)
137 | try:
138 | columns = cls._cf.get(rowkey, column_count=1, column_reversed=True)
139 | except tdb_cassandra.NotFoundException:
140 | return None
141 |
142 | u = columns.keys()[0]
143 | ts = convert_uuid_to_time(u)
144 | return datetime.utcfromtimestamp(ts).replace(tzinfo=g.tz)
145 |
146 |
147 | class Canvas(tdb_cassandra.View):
148 | _use_db = True
149 | _connection_pool = 'main'
150 | _compare_with = CompositeType(IntegerType(), IntegerType())
151 |
152 |
153 | """
154 | Super naive storage for the canvas, everything's in a single row.
155 |
156 | In the future we may want to break it up so that each C* row contains only
157 | a subset of all rows. That would spread the data out in the ring and
158 | would make it easy to grab regions of the canvas.
159 |
160 | """
161 |
162 | @classmethod
163 | def _rowkey(cls):
164 | return CANVAS_ID
165 |
166 | @classmethod
167 | def insert_pixel(cls, pixel):
168 | columns = {
169 | (pixel.x, pixel.y): json.dumps({
170 | "color": pixel.color,
171 | "timestamp": convert_uuid_to_time(pixel._id),
172 | "user_name": pixel.user_name,
173 | "user_fullname": pixel.user_fullname,
174 | })
175 | }
176 | cls._cf.insert(cls._rowkey(), columns)
177 |
178 | @classmethod
179 | def get(cls, x, y):
180 | column = (x, y)
181 | try:
182 | row = cls._cf.get(cls._rowkey(), columns=[column])
183 | except tdb_cassandra.NotFoundException:
184 | return {}
185 |
186 | d = row.get(column, '{}')
187 | pixel_dict = json.loads(d)
188 | return pixel_dict
189 |
190 | @classmethod
191 | def get_all(cls):
192 | """Return dict of (x,y) -> color"""
193 | try:
194 | gen = cls._cf.xget(cls._rowkey())
195 | except tdb_cassandra.NotFoundException:
196 | return {}
197 |
198 | return {
199 | (x, y): json.loads(d) for (x, y), d in gen
200 | }
201 |
--------------------------------------------------------------------------------
/reddit_place/public/static/css/place.less:
--------------------------------------------------------------------------------
1 | .place {
2 | user-select: none;
3 | -webkit-tap-highlight-color: rgba(0,0,0,0);
4 | }
5 |
6 | .place-embed {
7 | margin: 0;
8 | }
9 |
10 | .place-container {
11 | // TODO webkit
12 | align-items: center;
13 | background-color: #ddd;
14 | display: flex;
15 | height: 500px;
16 | justify-content: center;
17 | overflow: hidden;
18 | position: relative;
19 | width: auto;
20 | // TODO - remove
21 | // outline: solid yellow;
22 | }
23 |
24 | .place-viewer {
25 | transform-origin: center;
26 | // TODO - remove
27 | // outline: dotted red;
28 | }
29 |
30 | .place-camera {
31 | cursor: pointer;
32 | transform-origin: center;
33 | }
34 |
35 | .place-canvas {
36 | -ms-interpolation-mode: bicubic;
37 | image-rendering: -moz-crisp-edges;
38 | image-rendering: -webkit-optimize-contrast;
39 | image-rendering: crisp-edges; /* so crisp */
40 | image-rendering: pixelated;
41 | background-color: white;
42 | pointer-events: none;
43 | transform: translate(-.5px, -.5px);
44 | display: block;
45 | }
46 |
47 | .place-display-canvas {
48 | position: absolute;
49 | pointer-events: none;
50 | left: 0;
51 | right: 0;
52 | top: 0;
53 | bottom: 0;
54 | }
55 |
56 | .place-camera:active {
57 | cursor: -webkit-grab;
58 | }
59 |
60 | // This will be the thing for when you're holding a tile.
61 | // But that isn't implemented yet. So it's just a comment.
62 | // cursor: -webkit-grabbing;
63 |
64 | .place-bottom-toolbar {
65 | bottom: 0;
66 | left: 0;
67 | position: absolute;
68 | width: 100%;
69 | }
70 |
71 | .place-palette {
72 | background: #111111;
73 | box-sizing: border-box;
74 | cursor: pointer;
75 | left: 0;
76 | padding: 5px;
77 | text-align: center;
78 | width: 100%;
79 | }
80 |
81 | .place-palette > * {
82 | box-sizing: border-box;
83 | display: inline-block;
84 | height: 30px;
85 | vertical-align: top;
86 | width: 30px;
87 | }
88 |
89 | .place-swatch {
90 | &.place-selected {
91 | transform: scale(1.1, 1.1);
92 | box-shadow: 0 0 0 3px rgba(0, 0, 0, .4);
93 | border: 1px solid rgba(255, 255, 255, 0.4);
94 | }
95 | }
96 |
97 | .place-molly-guard {
98 | background-color: rgba(0, 0, 0, .6);
99 | background-image: data-uri('../place_icon_unlocked.png');
100 | background-position: center;
101 | background-repeat: no-repeat;
102 | background-size: 24px 41px;
103 | height: 100%;
104 | left: 0;
105 | opacity: 0;
106 | pointer-events: none;
107 | position: absolute;
108 | top: 0;
109 | transition: all .3s;
110 | width: 100%;
111 |
112 | &.place-locked {
113 | background-image: data-uri('../place_icon_locked.png');
114 | opacity: 1;
115 | }
116 | }
117 |
118 |
119 | .place-hand {
120 | position: absolute;
121 | top: 0;
122 | left: 0;
123 | pointer-events: none;
124 | }
125 |
126 | .place-hand-swatch {
127 | display: block;
128 | transform: translate(-50%, -50%);
129 | width: 40px;
130 | height: 40px;
131 | }
132 |
133 | .place-hand-cursor {
134 | background-color: rgba(0, 0, 0, 0.1);
135 | border: 1px solid rgba(0, 0, 0, 0.6);
136 | box-sizing: border-box;
137 | display: none;
138 | height: 40px;
139 | left: 0;
140 | pointer-events: none;
141 | position: absolute;
142 | top: 0;
143 | width: 40px;
144 | }
145 |
146 | .place-inspector {
147 | background: white;
148 | border: 1px solid #eeeeee;
149 | bottom: 100%;
150 | box-shadow: 0 2px 3px rgba(0, 0, 0, 0.3);
151 | display: none;
152 | font-size: 14px;
153 | left: 50%;
154 | padding: 20px;
155 | position: absolute;
156 | transform: translate(-50%, -20px);
157 |
158 | input {
159 | display: block;
160 | width: 100%;
161 | }
162 | }
163 |
164 | .place-uninitialized {
165 | display: none;
166 | }
167 |
168 | .place-camera-button,
169 | .place-mute-button,
170 | .place-zoom-button,
171 | .place-notification-button,
172 | .place-zoom-pulse {
173 | border-radius: 50%;
174 | border: none;
175 | height: 40px;
176 | outline: none;
177 | position: absolute;
178 | top: 10px;
179 | width: 40px;
180 | }
181 |
182 | .place-camera-button,
183 | .place-mute-button,
184 | .place-notification-button,
185 | .place-zoom-button {
186 | background-color: rgba(0, 0, 0, .6);
187 | background-position: center;
188 | background-repeat: no-repeat;
189 | background-size: 24px 24px;
190 | }
191 |
192 | .place-notification-button {
193 | display: none;
194 | top: 60px;
195 | left: 10px;
196 | background-image: data-uri('../place_notification_off.png');
197 | &.place-notification-on {
198 | background-image: data-uri('../place_notification_on.png');
199 | }
200 | }
201 |
202 | .place-camera-button {
203 | display: none;
204 | top: 60px;
205 | right: 10px;
206 | background-image: data-uri('../place_camera_track_on.png');
207 |
208 | &.place-following {
209 | background-image: data-uri('../place_camera_track_off.png');
210 | }
211 | }
212 |
213 | .place-zoom-button {
214 | background-image: data-uri('../place_icon_zoom_out.png');
215 | right: 10px;
216 |
217 | &.place-zoomed-out {
218 | background-image: data-uri('../place_icon_zoom_in.png');
219 | }
220 | }
221 |
222 |
223 | .place-zoom-pulse {
224 | position: absolute;
225 | left: -2px;
226 | top: -2px;
227 | border: 2px rgba(0, 0, 0, 0.6) solid;
228 | height: 100%;
229 | width: 100%;
230 |
231 | opacity: 0;
232 | .place-zoom-pulsing & {
233 | -webkit-animation: pulse 3s ease-out;
234 | -moz-animation: pulse 3s ease-out;
235 | animation: pulse 3s ease-out;
236 | -webkit-animation-iteration-count: 10;
237 | -moz-animation-iteration-count: 10;
238 | animation-iteration-count: 10;
239 | }
240 | }
241 |
242 |
243 | @-moz-keyframes pulse {
244 | 0% {
245 | -moz-transform: scale(0.8);
246 | opacity: 0.0;
247 | }
248 | 25% {
249 | -moz-transform: scale(0.8);
250 | opacity: 0.1;
251 | }
252 | 50% {
253 | -moz-transform: scale(1.05);
254 | opacity: 0.3;
255 | }
256 | 75% {
257 | -moz-transform: scale(1.2);
258 | opacity: 0.5;
259 | }
260 | 100% {
261 | -moz-transform: scale(1.3);
262 | opacity: 0.0;
263 | }
264 | }
265 |
266 | @-webkit-keyframes "pulse" {
267 | 0% {
268 | -webkit-transform: scale(0.8);
269 | opacity: 0.0;
270 | }
271 | 25% {
272 | -webkit-transform: scale(0.8);
273 | opacity: 0.1;
274 | }
275 | 50% {
276 | -webkit-transform: scale(1.05);
277 | opacity: 0.3;
278 | }
279 | 75% {
280 | -webkit-transform: scale(1.2);
281 | opacity: 0.5;
282 | }
283 | 100% {
284 | -webkit-transform: scale(1.3);
285 | opacity: 0.0;
286 | }
287 | }
288 |
289 |
290 | @-webkit-keyframes "pulse" {
291 | 0% {
292 | -webkit-transform: scale(0.8);
293 | opacity: 0.0;
294 | }
295 | 25% {
296 | -webkit-transform: scale(0.8);
297 | opacity: 0.1;
298 | }
299 | 50% {
300 | -webkit-transform: scale(1.05);
301 | opacity: 0.3;
302 | }
303 | 75% {
304 | -webkit-transform: scale(1.2);
305 | opacity: 0.5;
306 | }
307 | 100% {
308 | -webkit-transform: scale(1.3);
309 | opacity: 0.0;
310 | }
311 | }
312 |
313 |
314 | @keyframes "pulse" {
315 | 0% {
316 | transform: scale(0.8);
317 | opacity: 0.0;
318 | }
319 | 25% {
320 | transform: scale(0.8);
321 | opacity: 0.1;
322 | }
323 | 50% {
324 | transform: scale(1.05);
325 | opacity: 0.3;
326 | }
327 | 75% {
328 | transform: scale(1.2);
329 | opacity: 0.5;
330 | }
331 | 100% {
332 | transform: scale(1.3);
333 | opacity: 0.0;
334 | }
335 | }
336 |
337 |
338 | .place-mute-button {
339 | background-image: data-uri('../place_icon_audio_off.png');
340 | left: 10px;
341 |
342 | &.place-muted {
343 | background-image: data-uri('../place_icon_audio_on.png');
344 | }
345 |
346 | &.place-uninitialized {
347 | display: none;
348 | }
349 | }
350 |
351 | .place-timer {
352 | background: rgba(0, 0, 0, 0.6);
353 | border-radius: 10px;
354 | color: white;
355 | display: none;
356 | font-size: 20px;
357 | left: 50%;
358 | padding: 10px;
359 | position: absolute;
360 | top: 0;
361 | transform: translate(-50%, 10px);
362 | }
363 |
364 | .place-activity-count,
365 | .place-coordinates {
366 | background: rgba(255, 255, 255, .6);
367 | border-radius: 4px;
368 | bottom: 100%;
369 | color: #666666;
370 | font-size: 10px;
371 | height: auto;
372 | line-height: 12px;
373 | padding: 5px;
374 | position: absolute;
375 | vertical-align: middle;
376 | width: auto;
377 | }
378 |
379 | .place-coordinates {
380 | left: 0;
381 | transform: translate(10px, -10px);
382 | }
383 |
384 | .place-activity-count {
385 | font-weight: bold;
386 | right: 0;
387 | transform: translate(-10px, -10px);
388 |
389 | &:after {
390 | background-image: data-uri('../place_icon_activity.png');
391 | background-position: center;
392 | background-repeat: no-repeat;
393 | background-size: 12px 12px;
394 | content: '';
395 | display: inline-block;
396 | height: 12px;
397 | margin-left: 5px;
398 | vertical-align: middle;
399 | width: 12px;
400 | }
401 | }
402 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/init.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('init', function(require) {
2 | var $ = require('jQuery');
3 | var r = require('r');
4 |
5 | var Activity = require('activity');
6 | var AudioManager = require('audio');
7 | var bindEvents = require('utils').bindEvents;
8 | var Camera = require('camera');
9 | var CameraButton = require('camerabutton');
10 | var CameraButtonEvents = require('camerabuttonevents');
11 | var CameraEvents = require('cameraevents');
12 | var CanvasEvents = require('canvasevents');
13 | var Canvasse = require('canvasse');
14 | var Client = require('client');
15 | var Coordinates = require('coordinates');
16 | var Cursor = require('cursor');
17 | var Hand = require('hand');
18 | var Inspector = require('inspector');
19 | var Keyboard = require('keyboard');
20 | var MollyGuard = require('mollyguard');
21 | var MuteButton = require('mutebutton');
22 | var MuteButtonEvents = require('mutebuttonevents');
23 | var Notifications = require('notifications');
24 | var Palette = require('palette');
25 | var PaletteEvents = require('paletteevents');
26 | var R2Server = require('api');
27 | var Timer = require('timer');
28 | var WebsocketEvents = require('websocketevents');
29 | var ZoomButton = require('zoombutton');
30 | var ZoomButtonEvents = require('zoombuttonevents');
31 | var NotificationButton = require('notificationbutton');
32 | var NotificationButtonEvents = require('notificationbuttonevents');
33 |
34 | /**
35 | * Utility for kicking off an animation frame loop.
36 | * @function
37 | * @param {function} fn The function to call on each frame
38 | * @returns {function} A function that cancels the animation when called
39 | */
40 | function startTicking(fn) {
41 | var token = requestAnimationFrame(function tick() {
42 | fn();
43 | token = requestAnimationFrame(tick);
44 | });
45 |
46 | return function cancel() {
47 | cancelAnimationFrame(token);
48 | }
49 | }
50 |
51 | // Init code:
52 | $(function() {
53 | var activeVisitors = r.config.place_active_visitors;
54 | var isFullscreen = r.config.place_fullscreen;
55 | var isUiHidden = true;
56 | var isUserLoggedIn = false;
57 | var canvasWidth = r.config.place_canvas_width;
58 | var canvasHeight = r.config.place_canvas_height;
59 | var cooldownDuration = 1000 * r.config.place_cooldown;
60 | var websocketUrl = r.config.place_websocket_url;
61 | var waitSeconds = r.config.place_wait_seconds;
62 |
63 |
64 |
65 | var container = document.getElementById('place-container');
66 |
67 | // Bail out early if the container element isn't found – we're probably
68 | // running on some other page in r/place that doesn't have the canvas.
69 | if (!container) { return; }
70 |
71 | var activityCount = document.getElementById('place-activity-count');
72 | var viewer = document.getElementById('place-viewer');
73 | var camera = document.getElementById('place-camera');
74 | var cameraButton = document.getElementById('place-camera-button');
75 | var canvas = document.getElementById('place-canvasse');
76 | var coordinates = document.getElementById('place-coordinates');
77 | var palette = document.getElementById('place-palette');
78 | var hand = document.getElementById('place-hand');
79 | var handCursor = document.getElementById('place-hand-cursor');
80 | var handSwatch = document.getElementById('place-hand-swatch');
81 | var inspector = document.getElementById('place-inspector');
82 | var mollyGuard = document.getElementById('place-molly-guard');
83 | var muteButton = document.getElementById('place-mute-button');
84 | var zoomButton = document.getElementById('place-zoom-button');
85 | var notificationButton = document.getElementById('place-notification-button');
86 | var timer = document.getElementById('place-timer');
87 |
88 | function resizeToWindow() {
89 | $(container).css({
90 | height: window.innerHeight,
91 | width: window.innerWidth,
92 | });
93 | }
94 |
95 | if (isFullscreen) {
96 | resizeToWindow();
97 | $(window).on('resize', resizeToWindow);
98 | }
99 |
100 | Activity.init(activityCount, activeVisitors);
101 | AudioManager.init();
102 | Camera.init(viewer, camera);
103 |
104 | // Hack to fix slightly older versions of Safari, where the viewer element
105 | // defaults to fitting within the container width.
106 | $(viewer).css({
107 | flex: '0 0 ' + canvasWidth + 'px',
108 | });
109 |
110 | // Allow passing in starting camera position in the url hash
111 | var locationHash = window.location.hash.replace(/^#/, '');
112 | var hashParams = r.utils.parseQueryString(locationHash);
113 |
114 |
115 | Canvasse.init(canvas, canvasWidth, canvasHeight);
116 | CameraButton.init(cameraButton);
117 | CameraButton.enable();
118 | Hand.init(hand, handSwatch, handCursor);
119 | Inspector.init(inspector);
120 |
121 | if (r.config.logged) {
122 | Keyboard.init();
123 | }
124 |
125 | var isIOSFullscreen = (window.navigator.userAgent.indexOf('AppleWebKit') > -1 && window.innerHeight > 200);
126 | if (isIOSFullscreen) {
127 | NotificationButton.init(notificationButton);
128 | }
129 |
130 | if (isUserLoggedIn && !isUiHidden) {
131 | Palette.init(palette);
132 | }
133 |
134 | if (!isUiHidden) {
135 | MollyGuard.init(mollyGuard);
136 | if (AudioManager.isSupported) {
137 | MuteButton.init(muteButton);
138 | }
139 | ZoomButton.init(zoomButton);
140 | }
141 | Timer.init(timer);
142 | Notifications.init();
143 |
144 | // Clamp starting coordinates to the canvas boundries
145 | var halfWidth = canvasWidth / 2;
146 | var halfHeight = canvasHeight / 2;
147 |
148 | var randomBuffer = parseInt(canvasWidth / 10);
149 | var randomX = randomBuffer + parseInt(Math.random() * (canvasWidth - (randomBuffer * 2)), 10);
150 | var randomY = randomBuffer + parseInt(Math.random() * (canvasHeight - (randomBuffer * 2)), 10);
151 |
152 | var startX = Math.max(0, Math.min(canvasWidth, hashParams.x || randomX));
153 | var startY = Math.max(0, Math.min(canvasHeight, hashParams.y || randomY));
154 |
155 | Coordinates.init(coordinates, startX, startY);
156 |
157 | // Convert those values to canvas transform offsets
158 | // TODO - this shouldn't be done here, it requires Canvasse.init to be called first
159 | var startOffsets = Client.getOffsetFromCameraLocation(startX, startY);
160 |
161 | Client.init(isUserLoggedIn, cooldownDuration, startOffsets.x, startOffsets.y);
162 |
163 | if (isUserLoggedIn) {
164 | Client.setCooldownTime(waitSeconds * 1000);
165 | } else {
166 | Client.setCooldownTime(0);
167 | }
168 |
169 | var containerRect = container.getBoundingClientRect();
170 | Client.setContainerSize(containerRect.width, containerRect.height);
171 |
172 | $(window).on('resize', function() {
173 | var containerRect = container.getBoundingClientRect();
174 | Client.setContainerSize(containerRect.width, containerRect.height);
175 | });
176 |
177 | // Some browsers (Safari, Edge) have a blurry canvas problem due to
178 | // lack of proper support for the 'image-rendering' css rule, which is
179 | // what allows us to scale up the canvas without bilinear interpolation.
180 | // We can still upscale correctly by drawing the small canvas into a bigger
181 | // canvas using the imageSmoothingEnabled flag.
182 | var canvasDiv = null;
183 | var displayCanvas = null;
184 | var displayCtx = null;
185 | var usingBlurryCanvasFix = false;
186 |
187 | // Only apply to browsers where this is a known issue.
188 | var isSafari = (window.navigator.userAgent.indexOf('Safari') > -1 &&
189 | window.navigator.userAgent.indexOf('Chrome') === -1);
190 | // Necessary to catch webview embedded in native iOS app
191 | var isIOS = (window.navigator.userAgent.indexOf('iOS') > -1 ||
192 | window.navigator.userAgent.indexOf('iPhone') > -1 ||
193 | window.navigator.userAgent.indexOf('iPad') > -1);
194 | var isEdge = window.navigator.userAgent.indexOf('Edge') > -1;
195 | if (isSafari || isIOS || isEdge) {
196 | usingBlurryCanvasFix = true;
197 | // To avoid having to redo event work, we just let the existing canvas
198 | // element sit there invisibly.
199 | $(Canvasse.el).css({ opacity: 0 });
200 | displayCanvas = document.createElement('canvas');
201 | displayCtx = displayCanvas.getContext('2d');
202 | $(displayCanvas).addClass('place-display-canvas');
203 | $(container).prepend(displayCanvas);
204 | resizeDisplayCanvas();
205 |
206 | bindEvents(window, {
207 | 'resize': function() {
208 | resizeDisplayCanvas();
209 | },
210 | });
211 | }
212 |
213 | function resizeDisplayCanvas() {
214 | var containerRect = container.getBoundingClientRect();
215 | displayCanvas.width = containerRect.width;
216 | displayCanvas.height = containerRect.height;
217 | // Here's the magic. These flags are reset any time the canvas resize
218 | // changes, so they need to be set here.
219 | displayCtx.mozImageSmoothingEnabled = false;
220 | displayCtx.webkitImageSmoothingEnabled = false;
221 | displayCtx.msImageSmoothingEnabled = false;
222 | displayCtx.imageSmoothingEnabled = false;
223 | redrawDisplayCanvas();
224 | }
225 |
226 | function redrawDisplayCanvas() {
227 | displayCtx.clearRect(0, 0, displayCanvas.width, displayCanvas.height);
228 | displayCtx.drawImage(
229 | Canvasse.el,
230 | // Center the canvas, then apply the current pan offset. The half pixel
231 | // css it necessary to match the same offset applied to the real canvas
232 | // via a css transform.
233 | (displayCanvas.width / 2) + (Client._panX - halfWidth - .5) * Client._zoom,
234 | (displayCanvas.height / 2) + (Client._panY - halfHeight - .5) * Client._zoom,
235 | Canvasse.width * Client._zoom,
236 | Canvasse.height * Client._zoom
237 | );
238 | }
239 |
240 | var minLoadingX = startX - 2;
241 | var loadingWidth = 5;
242 | var loadingDir = 1;
243 | var loadingX = 0;
244 | var loadingY = startY;
245 | var loadingTicks = 0;
246 | var loadingTicksPerFrame = 10;
247 |
248 | var loadingAnimationCancel = startTicking(function() {
249 | loadingTicks = (loadingTicks + 1) % loadingTicksPerFrame;
250 | // only show when ticks is 0
251 | if (loadingTicks) { return; }
252 | // erase tile from the previous frame
253 | Canvasse.drawRectToDisplay(minLoadingX, loadingY, loadingWidth, 1, 'grey');
254 | // increment position
255 | loadingX = (loadingX + loadingDir) % loadingWidth;
256 | // draw new tile
257 | Canvasse.drawTileToDisplay(minLoadingX + loadingX, loadingY, 'black');
258 | });
259 |
260 | R2Server.getCanvasBitmapState().then(function(timestamp, canvas) {
261 | // TODO - request non-cached version if the timestamp is too old
262 | if (!canvas) { return; }
263 |
264 | loadingAnimationCancel();
265 | Canvasse.clearRectFromDisplay(minLoadingX, loadingY, loadingWidth, 1);
266 | Client.setInitialState(canvas);
267 | if (usingBlurryCanvasFix) {
268 | redrawDisplayCanvas();
269 | }
270 | if (Client.isZoomedIn) {
271 | Client.toggleZoom();
272 | }
273 |
274 | });
275 |
276 | var websocket = new r.WebSocket(websocketUrl);
277 | websocket.on(WebsocketEvents);
278 | websocket.start();
279 |
280 | // TODO - fix this weird naming?
281 | bindEvents(container, CameraEvents['container']);
282 | bindEvents(document, CameraEvents['document']);
283 | bindEvents(camera, CanvasEvents);
284 | bindEvents(cameraButton, CameraButtonEvents);
285 | bindEvents(muteButton, MuteButtonEvents);
286 | bindEvents(zoomButton, ZoomButtonEvents);
287 | bindEvents(notificationButton, NotificationButtonEvents);
288 |
289 | if (isUserLoggedIn) {
290 | bindEvents(palette, PaletteEvents);
291 | }
292 |
293 | function shouldMouseOutCancel(e) {
294 | // Events are stupid
295 | return !(e.target === camera || e.relatedTarget === camera) && Cursor.isDown;
296 | }
297 |
298 | bindEvents(container, {
299 | 'mouseout': function(e) {
300 | if (shouldMouseOutCancel(e)) {
301 | return CameraEvents['container']['mouseup'](e);
302 | }
303 | },
304 |
305 | // Map touch events to mouse events. Note that this works since
306 | // currently the event handlers only use the clientX and clientY
307 | // properties of the MouseEvent objects (which the Touch objects
308 | // also have. If the handlers start using other properties or
309 | // methods of the MouseEvent that the Touch objects *don't* have,
310 | // this will probably break.
311 | 'touchstart': function(e) {
312 | if (!Cursor.isUsingTouch) {
313 | Cursor.setTouchMode(true);
314 | }
315 | return CameraEvents['container']['mousedown'](e.changedTouches[0]);
316 | },
317 |
318 | 'touchmove': function(e) {
319 | e.preventDefault();
320 | return CameraEvents['container']['mousemove'](e.changedTouches[0]);
321 | },
322 |
323 | 'touchend': function(e) {
324 | return CameraEvents['container']['mouseup'](e.changedTouches[0]);
325 | },
326 |
327 | 'touchcancel': function(e) {
328 | if (shouldMouseOutCancel(e)) {
329 | return CameraEvents['container']['mouseup'](e.changedTouches[0]);
330 | }
331 | },
332 | });
333 |
334 | bindEvents(palette, {
335 | 'touchstart': function(e) {
336 | if (!Cursor.isUsingTouch) {
337 | Cursor.setTouchMode(true);
338 | }
339 | },
340 | });
341 |
342 | // Move the camera if the hash params changedTouches
343 | bindEvents(window, {
344 | 'hashchange': function(e) {
345 | var locationHash = window.location.hash.replace(/^#/, '');
346 | var hashParams = r.utils.parseQueryString(locationHash);
347 |
348 | if (hashParams.x && hashParams.y) {
349 | Client.interact();
350 | Client.setCameraLocation(hashParams.x, hashParams.y);
351 | }
352 | },
353 | });
354 |
355 | startTicking(function() {
356 | Keyboard.tick();
357 | Client.tick();
358 | Cursor.tick();
359 | var cameraDidUpdate = Camera.tick();
360 | var canvasDidUpdate = Canvasse.tick();
361 |
362 | if (usingBlurryCanvasFix && (cameraDidUpdate || canvasDidUpdate)) {
363 | redrawDisplayCanvas();
364 | }
365 | });
366 |
367 | r.place = Client;
368 |
369 | window.addEventListener('message', function (e) {
370 | if (e.origin == "https://www.reddit.com") {
371 | try {
372 | var data = JSON.parse(e.data);
373 | } catch (e) {
374 | return;
375 | }
376 |
377 | if (data.name == 'PLACE_MESSAGE' && data.payload) {
378 | R2Server.injectHeaders(data.payload);
379 |
380 | // This is allows mweb to show and use the color
381 | // palette without a reddit_session using only
382 | // a valid token.
383 | if (!isUserLoggedIn) {
384 | Palette.init(palette);
385 | Palette.generateSwatches(Client.DEFAULT_COLOR_PALETTE);
386 | Client.enable();
387 | bindEvents(palette, PaletteEvents);
388 | }
389 | }
390 | }
391 | });
392 |
393 | r.hooks.call('place.init');
394 | });
395 | });
396 |
--------------------------------------------------------------------------------
/reddit_place/controllers.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 | import time
3 |
4 | from pylons import app_globals as g
5 | from pylons import tmpl_context as c
6 | from pylons import response, request
7 | from pylons.i18n import _
8 |
9 | from r2.config import feature
10 | from r2.controllers import add_controller
11 | from r2.controllers.reddit_base import (
12 | RedditController,
13 | set_content_type,
14 | )
15 | from r2.lib import hooks
16 | from r2.lib import (
17 | baseplate_integration,
18 | websockets,
19 | )
20 | from r2.lib.base import BaseController
21 | from r2.lib.errors import errors
22 | from r2.lib.pages import SideBox
23 | from r2.lib.utils import SimpleSillyStub
24 | from r2.lib.validator import (
25 | json_validate,
26 | validate,
27 | VAdmin,
28 | VBoolean,
29 | VColor,
30 | VInt,
31 | VModhash,
32 | VUser,
33 | )
34 | from r2.models import Subreddit
35 | from r2.controllers.oauth2 import (
36 | allow_oauth2_access,
37 | )
38 |
39 | from . import events
40 | from .models import (
41 | CANVAS_ID,
42 | CANVAS_WIDTH,
43 | CANVAS_HEIGHT,
44 | Pixel,
45 | RedisCanvas,
46 | )
47 | from .pages import (
48 | PlaceEmbedPage,
49 | PlacePage,
50 | PlaceCanvasse,
51 | )
52 |
53 |
54 | controller_hooks = hooks.HookRegistrar()
55 |
56 |
57 | ACCOUNT_CREATION_CUTOFF = datetime(2017, 3, 31, 0, 0, tzinfo=g.tz)
58 | PIXEL_COOLDOWN_SECONDS = 300
59 | PIXEL_COOLDOWN = timedelta(seconds=PIXEL_COOLDOWN_SECONDS)
60 | ADMIN_RECT_DRAW_MAX_SIZE = 20
61 | PLACE_SUBREDDIT = Subreddit._by_name("place", stale=True)
62 |
63 |
64 | @add_controller
65 | class LoggedOutPlaceController(BaseController):
66 | def pre(self):
67 | BaseController.pre(self)
68 |
69 | action = request.environ["pylons.routes_dict"].get("action")
70 | if action:
71 | if not self._get_action_handler():
72 | action = 'invalid'
73 | controller = request.environ["pylons.routes_dict"]["controller"]
74 | timer_name = "service_time.web.{}.{}".format(controller, action)
75 | c.request_timer = g.stats.get_timer(timer_name)
76 | else:
77 | c.request_timer = SimpleSillyStub()
78 |
79 | c.request_timer.start()
80 |
81 | if "Origin" in request.headers:
82 | oauth_origin = "https://%s" % g.oauth_domain
83 | response.headers["Access-Control-Allow-Origin"] = oauth_origin
84 | response.headers["Vary"] = "Origin"
85 | response.headers["Access-Control-Allow-Methods"] = "GET"
86 | response.headers["Access-Control-Allow-Credentials"] = "true"
87 |
88 | # We want to be able to cache some endpoints regardless of whether or not
89 | # the user is logged in. For this, we need to inherit from
90 | # BaseController. This lets us avoid the cache poisoning and logged in
91 | # checks embedded in MinimalController that would prevent caching.
92 |
93 | def _get_board_bitmap(self):
94 |
95 | # Since we're not using MinimalController, we need to setup the
96 | # baseplate span manually to have access to the baseplate context.
97 | baseplate_integration.make_server_span(
98 | span_name="place.GET_board_bitmap").start()
99 | response = RedisCanvas.get_board()
100 | baseplate_integration.finish_server_span()
101 | return response
102 |
103 | @allow_oauth2_access
104 | def GET_board_bitmap(self):
105 | """
106 | Get board bitmap with cache control determined by GET parames.
107 | """
108 |
109 | # nocache
110 | if 'nocache' in request.GET:
111 | response.headers['Cache-Control'] = 'private'
112 | else:
113 | response.headers['Cache-Control'] = \
114 | 'max-age=1, stale-while-revalidate=1'
115 |
116 | # nostalecache
117 | dont_stalecache = 'nostalecache' in request.GET or not g.stalecache
118 | if dont_stalecache:
119 | board_bitmap = None
120 | else:
121 | board_bitmap = g.stalecache.get('place:board_bitmap')
122 |
123 | # redis
124 | if not board_bitmap:
125 | board_bitmap = self._get_board_bitmap()
126 | if not dont_stalecache:
127 | g.stalecache.set('place:board_bitmap', board_bitmap, time=1,
128 | noreply=True)
129 |
130 | return self._get_board_bitmap()
131 |
132 | def post(self):
133 | c.request_timer.stop()
134 | g.stats.flush()
135 |
136 | # This should never happen. Our routes should never be changing the
137 | # login status of a user. Still, since we plan on heavily caching
138 | # these routes, it's better safe than sorry. We don't want to
139 | # accidentally cache sensitive information.
140 | for k, v in response.headers.iteritems():
141 | assert k != 'Set-Cookie'
142 |
143 |
144 | class ActivityError:
145 | pass
146 |
147 |
148 | def get_activity_count():
149 | activity = PLACE_SUBREDDIT.count_activity()
150 |
151 | if not activity:
152 | raise ActivityError
153 |
154 | count = 0
155 | for context_name in Subreddit.activity_contexts:
156 | context_activity = getattr(activity, context_name, None)
157 | if context_activity:
158 | count += context_activity.count
159 | return count
160 |
161 |
162 | @add_controller
163 | class PlaceController(RedditController):
164 | def pre(self):
165 | RedditController.pre(self)
166 |
167 | if not PLACE_SUBREDDIT.can_view(c.user):
168 | self.abort403()
169 |
170 | if c.user.in_timeout:
171 | self.abort403()
172 |
173 | if c.user._spam:
174 | self.abort403()
175 |
176 | @validate(
177 | is_embed=VBoolean("is_embed"),
178 | is_webview=VBoolean("webview", default=False),
179 | is_palette_hidden=VBoolean('hide_palette', default=False),
180 | )
181 | @allow_oauth2_access
182 | def GET_canvasse(self, is_embed, is_webview, is_palette_hidden):
183 | # oauth will try to force the response into json
184 | # undo that here by hacking extension, content_type, and render_style
185 | try:
186 | del(request.environ['extension'])
187 | except:
188 | pass
189 | request.environ['content_type'] = "text/html; charset=UTF-8"
190 | request.environ['render_style'] = "html"
191 | set_content_type()
192 |
193 | websocket_url = websockets.make_url("/place", max_age=3600)
194 |
195 | content = PlaceCanvasse()
196 |
197 | js_config = {
198 | "place_websocket_url": websocket_url,
199 | "place_canvas_width": CANVAS_WIDTH,
200 | "place_canvas_height": CANVAS_HEIGHT,
201 | "place_cooldown": 0 if c.user_is_admin else PIXEL_COOLDOWN_SECONDS,
202 | "place_fullscreen": is_embed or is_webview,
203 | "place_hide_ui": is_palette_hidden,
204 | }
205 |
206 | if c.user_is_loggedin and not c.user_is_admin:
207 | js_config["place_wait_seconds"] = get_wait_seconds(c.user)
208 |
209 | # this is a sad duplication of the same from reddit_base :(
210 | if c.user_is_loggedin:
211 | PLACE_SUBREDDIT.record_visitor_activity("logged_in", c.user._fullname)
212 | elif c.loid.serializable:
213 | PLACE_SUBREDDIT.record_visitor_activity("logged_out", c.loid.loid)
214 |
215 | try:
216 | js_config["place_active_visitors"] = get_activity_count()
217 | except ActivityError:
218 | pass
219 |
220 | if is_embed:
221 | # ensure we're off the cookie domain before allowing embedding
222 | if request.host != g.media_domain:
223 | abort(404)
224 | c.allow_framing = True
225 |
226 | if is_embed or is_webview:
227 | return PlaceEmbedPage(
228 | title="place",
229 | content=content,
230 | extra_js_config=js_config,
231 | ).render()
232 | else:
233 | return PlacePage(
234 | title="place",
235 | content=content,
236 | extra_js_config=js_config,
237 | ).render()
238 |
239 | @json_validate(
240 | VUser(), # NOTE: this will respond with a 200 with an error body
241 | VModhash(),
242 | x=VInt("x", min=0, max=CANVAS_WIDTH, coerce=False),
243 | y=VInt("y", min=0, max=CANVAS_HEIGHT, coerce=False),
244 | color=VInt("color", min=0, max=15),
245 | )
246 | @allow_oauth2_access
247 | def POST_draw(self, responder, x, y, color):
248 |
249 | # End the game
250 | self.abort403()
251 |
252 | if c.user._date >= ACCOUNT_CREATION_CUTOFF:
253 | self.abort403()
254 |
255 | if PLACE_SUBREDDIT.is_banned(c.user):
256 | self.abort403()
257 |
258 | if x is None:
259 | # copy the error set by VNumber/VInt
260 | c.errors.add(
261 | error_name=errors.BAD_NUMBER,
262 | field="x",
263 | msg_params={
264 | "range": _("%(min)d to %(max)d") % {
265 | "min": 0,
266 | "max": CANVAS_WIDTH,
267 | },
268 | },
269 | )
270 |
271 | if y is None:
272 | # copy the error set by VNumber/VInt
273 | c.errors.add(
274 | error_name=errors.BAD_NUMBER,
275 | field="y",
276 | msg_params={
277 | "range": _("%(min)d to %(max)d") % {
278 | "min": 0,
279 | "max": CANVAS_HEIGHT,
280 | },
281 | },
282 | )
283 |
284 | if color is None:
285 | c.errors.add(errors.BAD_COLOR, field="color")
286 |
287 | if (responder.has_errors("x", errors.BAD_NUMBER) or
288 | responder.has_errors("y", errors.BAD_NUMBER) or
289 | responder.has_errors("color", errors.BAD_COLOR)):
290 | # TODO: return 400 with parsable error message?
291 | return
292 |
293 | if c.user_is_admin:
294 | wait_seconds = 0
295 | else:
296 | wait_seconds = get_wait_seconds(c.user)
297 |
298 | if wait_seconds > 2:
299 | response.status = 429
300 | request.environ['extra_error_data'] = {
301 | "error": 429,
302 | "wait_seconds": wait_seconds,
303 | }
304 | return
305 |
306 | Pixel.create(c.user, color, x, y)
307 |
308 | c.user.set_flair(
309 | subreddit=PLACE_SUBREDDIT,
310 | text="({x},{y}) {time}".format(x=x, y=y, time=time.time()),
311 | css_class="place-%s" % color,
312 | )
313 |
314 | websockets.send_broadcast(
315 | namespace="/place",
316 | type="place",
317 | payload={
318 | "author": c.user.name,
319 | "x": x,
320 | "y": y,
321 | "color": color,
322 | }
323 | )
324 |
325 | events.place_pixel(x, y, color)
326 | cooldown = 0 if c.user_is_admin else PIXEL_COOLDOWN_SECONDS
327 | return {
328 | 'wait_seconds': cooldown,
329 | }
330 |
331 | @json_validate(
332 | VUser(), # NOTE: this will respond with a 200 with an error body
333 | VAdmin(),
334 | VModhash(),
335 | x=VInt("x", min=0, max=CANVAS_WIDTH, coerce=False),
336 | y=VInt("y", min=0, max=CANVAS_HEIGHT, coerce=False),
337 | width=VInt("width", min=1, max=ADMIN_RECT_DRAW_MAX_SIZE,
338 | coerce=True, num_default=1),
339 | height=VInt("height", min=1, max=ADMIN_RECT_DRAW_MAX_SIZE,
340 | coerce=True, num_default=1),
341 | )
342 | @allow_oauth2_access
343 | def POST_drawrect(self, responder, x, y, width, height):
344 | if x is None:
345 | # copy the error set by VNumber/VInt
346 | c.errors.add(
347 | error_name=errors.BAD_NUMBER,
348 | field="x",
349 | msg_params={
350 | "range": _("%(min)d to %(max)d") % {
351 | "min": 0,
352 | "max": CANVAS_WIDTH,
353 | },
354 | },
355 | )
356 |
357 | if y is None:
358 | # copy the error set by VNumber/VInt
359 | c.errors.add(
360 | error_name=errors.BAD_NUMBER,
361 | field="y",
362 | msg_params={
363 | "range": _("%(min)d to %(max)d") % {
364 | "min": 0,
365 | "max": CANVAS_HEIGHT,
366 | },
367 | },
368 | )
369 |
370 | if (responder.has_errors("x", errors.BAD_NUMBER) or
371 | responder.has_errors("y", errors.BAD_NUMBER)):
372 | # TODO: return 400 with parsable error message?
373 | return
374 |
375 | # prevent drawing outside of the canvas
376 | width = min(CANVAS_WIDTH - x, width)
377 | height = min(CANVAS_HEIGHT - y, height)
378 |
379 | batch_payload = []
380 |
381 | for _x in xrange(x, x + width):
382 | for _y in xrange(y, y + height):
383 | pixel = Pixel.create(None, 0, _x, _y)
384 | payload = {
385 | "author": '',
386 | "x": _x,
387 | "y": _y,
388 | "color": 0,
389 | }
390 | batch_payload.append(payload)
391 |
392 | websockets.send_broadcast(
393 | namespace="/place",
394 | type="batch-place",
395 | payload=batch_payload,
396 | )
397 |
398 | @json_validate(
399 | VUser(),
400 | )
401 | @allow_oauth2_access
402 | def GET_time_to_wait(self, responder):
403 | if c.user._date >= ACCOUNT_CREATION_CUTOFF:
404 | self.abort403()
405 |
406 | if c.user_is_admin:
407 | wait_seconds = 0
408 | else:
409 | wait_seconds = get_wait_seconds(c.user)
410 |
411 | return {
412 | "wait_seconds": wait_seconds,
413 | }
414 |
415 | @json_validate(
416 | x=VInt("x", min=0, max=CANVAS_WIDTH, coerce=False),
417 | y=VInt("y", min=0, max=CANVAS_HEIGHT, coerce=False),
418 | )
419 | @allow_oauth2_access
420 | def GET_pixel(self, responder, x, y):
421 | if x is None:
422 | # copy the error set by VNumber/VInt
423 | c.errors.add(
424 | error_name=errors.BAD_NUMBER,
425 | field="x",
426 | msg_params={
427 | "range": _("%(min)d to %(max)d") % {
428 | "min": 0,
429 | "max": CANVAS_WIDTH,
430 | },
431 | },
432 | )
433 |
434 | if y is None:
435 | # copy the error set by VNumber/VInt
436 | c.errors.add(
437 | error_name=errors.BAD_NUMBER,
438 | field="y",
439 | msg_params={
440 | "range": _("%(min)d to %(max)d") % {
441 | "min": 0,
442 | "max": CANVAS_HEIGHT,
443 | },
444 | },
445 | )
446 |
447 | if (responder.has_errors("x", errors.BAD_NUMBER) or
448 | responder.has_errors("y", errors.BAD_NUMBER)):
449 | return
450 |
451 | pixel = Pixel.get_pixel_at(x, y)
452 | if pixel and pixel["user_name"]:
453 | # pixels blanked out by admins will not have a user_name set
454 | return pixel
455 |
456 |
457 | def get_wait_seconds(user):
458 | last_pixel_dt = Pixel.get_last_placement_datetime(user)
459 | now = datetime.now(g.tz)
460 |
461 | if last_pixel_dt and last_pixel_dt + PIXEL_COOLDOWN > now:
462 | next_pixel_dt = last_pixel_dt + PIXEL_COOLDOWN
463 | wait_seconds = (next_pixel_dt - now).total_seconds()
464 | else:
465 | wait_seconds = 0
466 |
467 | return wait_seconds
468 |
469 |
470 | @controller_hooks.on("hot.get_content")
471 | def add_canvasse(controller):
472 | if c.site.name == PLACE_SUBREDDIT.name:
473 | return PlaceCanvasse()
474 |
475 |
476 | @controller_hooks.on("js_config")
477 | def add_place_config(config):
478 | if c.site.name == PLACE_SUBREDDIT.name:
479 | cooldown = 0 if c.user_is_admin else PIXEL_COOLDOWN_SECONDS
480 | websocket_url = websockets.make_url("/place", max_age=3600)
481 | config["place_websocket_url"] = websocket_url
482 | config["place_canvas_width"] = CANVAS_WIDTH
483 | config["place_canvas_height"] = CANVAS_HEIGHT
484 | config["place_cooldown"] = cooldown
485 | if c.user_is_loggedin and not c.user_is_admin:
486 | config["place_wait_seconds"] = get_wait_seconds(c.user)
487 |
488 | try:
489 | config["place_active_visitors"] = get_activity_count()
490 | except ActivityError:
491 | pass
492 |
493 |
494 | @controller_hooks.on("extra_stylesheets")
495 | def add_place_stylesheet(extra_stylesheets):
496 | if c.site.name == PLACE_SUBREDDIT.name:
497 | extra_stylesheets.append("place.less")
498 |
499 |
500 | @controller_hooks.on("extra_js_modules")
501 | def add_place_js_module(extra_js_modules):
502 | if c.site.name == PLACE_SUBREDDIT.name:
503 | extra_js_modules.append("place-base")
504 | if c.user_is_admin:
505 | extra_js_modules.append("place-admin")
506 | extra_js_modules.append("place-init")
507 |
508 |
509 | @controller_hooks.on('home.add_sidebox')
510 | def add_home_sidebox():
511 | if not feature.is_enabled('place_on_homepage'):
512 | return None
513 |
514 | return SideBox(
515 | title="PLACE",
516 | css_class="place_sidebox",
517 | link="/r/place",
518 | target="_blank",
519 | )
520 |
--------------------------------------------------------------------------------
/reddit_place/public/static/js/place/client.js:
--------------------------------------------------------------------------------
1 | !r.placeModule('client', function(require) {
2 | var $ = require('jQuery');
3 | var r = require('r');
4 | var store = require('store');
5 |
6 | var AudioManager = require('audio');
7 | var Camera = require('camera');
8 | var CameraButton = require('camerabutton');
9 | var Canvasse = require('canvasse');
10 | var Coordinates = require('coordinates');
11 | var Hand = require('hand');
12 | var Inspector = require('inspector');
13 | var Keyboard = require('keyboard');
14 | var MollyGuard = require('mollyguard');
15 | var MuteButton = require('mutebutton');
16 | var NotificationButton = require('notificationbutton');
17 | var Notifications = require('notifications');
18 | var Palette = require('palette');
19 | var R2Server = require('api');
20 | var Timer = require('timer');
21 | var lerp = require('utils').lerp;
22 | var ZoomButton = require('zoombutton');
23 | var parseHexColor = require('utils').parseHexColor;
24 | var clamp = require('utils').clamp;
25 | var getDistance = require('utils').getDistance;
26 | var normalizeVector = require('utils').normalizeVector;
27 |
28 | var MAX_COLOR_INDEX = 15;
29 | var DEFAULT_COLOR = '#FFFFFF';
30 | var DEFAULT_COLOR_ABGR = 0xFFFFFFFF;
31 |
32 | // Define some sound effects, to be played with AudioManager.playClip
33 | var SFX_DROP = AudioManager.compileClip([
34 | ['E7', 1/32], ['C7', 1/32], ['A6', 1/16],
35 | ]);
36 | var SFX_PLACE = AudioManager.compileClip([
37 | ['G7', 1/32], ['E7', 1/32], ['C6', 1/16],
38 | ]);
39 | var SFX_SELECT = AudioManager.compileClip([
40 | ['C7', 1/32], ['E7', 1/32], ['G8', 1/16],
41 | ]);
42 | var SFX_ERROR = AudioManager.compileClip([
43 | ['E4', 1/32], ['C4', 1/32], ['A3', 1/16],
44 | ])
45 | var SFX_ZOOM_OUT = AudioManager.compileClip([
46 | ['D6', 1/32], ['C6', 1/32], ['A5', 1/16],
47 | ]);;
48 | var SFX_ZOOM_IN = SFX_ZOOM_OUT.slice().reverse();
49 |
50 | // Used to keep a list of the most recent n pixel updates received.
51 | var recentTiles = [];
52 | var recentTilesIndex = 0;
53 | var maxrecentTilesLength = 100;
54 |
55 | var autoCameraIntervalToken;
56 |
57 | var B = 0;
58 | var k = 1;
59 | var f = .5;
60 | var g = 1;
61 |
62 | /**
63 | * Rossmo Formula.
64 | * https://en.wikipedia.org/wiki/Rossmo%27s_formula
65 | * Using this as a rough way of determining where the most interesting part
66 | * of the board might be.
67 | * @param {Object} a { x, y } coordinate object
68 | * @param {Object[]} ns array of { x, y } coordinate objects
69 | * @param {number} B "buffer" zone size
70 | * @param {number} k used to scale the entire results. Essentially
71 | * meaningless in this context since we're just selecting the max anyway.
72 | * Anything greater than 0 should be fine.
73 | * @param {number} f configurable value, I don't understand it.
74 | * @param {number} g configurable value, I don't understand it.
75 | */
76 | function rossmoFormula(a, ns, B, k, f, g) {
77 | return k * ns.reduce(function(acc, n) {
78 | const d = Math.abs(a.x - n.x) + Math.abs(a.y - n.y)
79 | if (!d) {
80 | return acc; // not sure if I need the 1 there
81 | } else if (d > B) {
82 | return acc + 1 / Math.pow(d, f);
83 | } else {
84 | return acc + Math.pow(B, g - f) / Math.pow(d, g);
85 | }
86 | }, 0); // not sure if this ought to be at least 1
87 | }
88 |
89 | // Handles actions the local user takes.
90 | return {
91 | AUTOCAMERA_INTERVAL: 3000,
92 | ZOOM_LERP_SPEED: .2,
93 | PAN_LERP_SPEED: .4,
94 | ZOOM_MAX_SCALE: 40,
95 | ZOOM_MIN_SCALE: 4,
96 | VOLUME_LEVEL: .1,
97 | MAXIMUM_AUDIBLE_DISTANCE: 10,
98 | WORLD_AUDIO_MULTIPLIER: .1,
99 | MAX_WORLD_AUDIO_RATE: 250,
100 | KEYBOARD_PAN_SPEED: .5,
101 | KEYBOARD_PAN_LERP_SPEED: .275,
102 |
103 | DEFAULT_COLOR_PALETTE: [
104 | '#FFFFFF', // white
105 | '#E4E4E4', // light grey
106 | '#888888', // grey
107 | '#222222', // black
108 | '#FFA7D1', // pink
109 | '#E50000', // red
110 | '#E59500', // orange
111 | '#A06A42', // brown
112 | '#E5D900', // yellow
113 | '#94E044', // lime
114 | '#02BE01', // green
115 | '#00D3DD', // cyan
116 | '#0083C7', // blue
117 | '#0000EA', // dark blue
118 | '#CF6EE4', // magenta
119 | '#820080', // purple
120 | ],
121 |
122 | state: null,
123 | autoCameraEnabled: false,
124 | colorIndex: null,
125 | paletteColor: null,
126 | cooldown: 0,
127 | cooldownEndTime: 0,
128 | cooldownPromise: null,
129 | palette: null,
130 | enabled: true,
131 | isZoomedIn: false,
132 | isPanEnabled: true,
133 | lastWorldAudioTime: 0,
134 | isWorldAudioEnabled: true,
135 | containerSize: { width: 0, height: 0 },
136 | panX: 0,
137 | panY: 0,
138 | zoom: 1,
139 | currentDirection: { x: 0, y: 0 },
140 | // For values that can be 'lerp'ed, copies of the attribute
141 | // prefixed with an underscore (e.g. _zoom) are used to track
142 | // the current *actual* value, while the unprefixed attribute
143 | // tracks the *target* value.
144 | _panX: 0,
145 | _panY: 0,
146 | _zoom: 1,
147 | _currentDirection: { x: 0, y: 0 },
148 |
149 | /**
150 | * Initialize
151 | * @function
152 | * @param {boolean} isEnabled Is the client enabled.
153 | * @param {number} cooldown The amount of time in ms users must wait between draws.
154 | * @param {?number} panX Horizontal camera offset
155 | * @param {?number} panY Vertical camera offset
156 | * @param {?string} color Hex-formatted color string
157 | */
158 | init: function(isEnabled, cooldown, panX, panY, color) {
159 | // If logged out, client is disabled. If logged in, client is
160 | // initially disabled until we get the API response back to know
161 | // whether they can place.
162 | this.enabled = false;
163 | this.isZoomedIn = true;
164 | this.cooldown = cooldown;
165 | if (color) this.setColor(color, false);
166 |
167 | this.setZoom(this.isZoomedIn ? this.ZOOM_MAX_SCALE : this.ZOOM_MIN_SCALE);
168 | this.setOffset(panX|0, panY|0);
169 | AudioManager.setGlobalVolume(this.VOLUME_LEVEL);
170 | this.setColorPalette(this.DEFAULT_COLOR_PALETTE);
171 | Palette.generateSwatches(this.DEFAULT_COLOR_PALETTE);
172 |
173 | // We store whether the user has turned off audio in localStorage.
174 | var audioIsDisabled = !!store.safeGet('place-audio-isDisabled');
175 | if (audioIsDisabled) {
176 | this._setAudioEnabled(false);
177 | }
178 |
179 | if (!this.getZoomButtonClicked()) {
180 | ZoomButton.highlight(true);
181 | }
182 |
183 | var isNotificationButtonEnabled = parseInt(store.safeGet('iOS-Notifications-Enabled'), 10) === 1;
184 | if (isNotificationButtonEnabled) {
185 | NotificationButton.showNotificationOn();
186 | }
187 |
188 | this.state = new Uint8Array(new ArrayBuffer(Canvasse.width * Canvasse.height));
189 | },
190 |
191 | /**
192 | * Set the color palette.
193 | * @function
194 | * @param {string[]} palette An array of valid css color strings
195 | */
196 | setColorPalette: function(palette) {
197 | var isNew = this.palette === null;
198 |
199 | this.palette = palette;
200 | Palette.generateSwatches(palette);
201 | // The internal color palette structure stores colors as AGBR (reversed
202 | // RGBA) to make writing to the color buffer easier.
203 | var dataView = new DataView(new ArrayBuffer(4));
204 | // The first byte is alpha, which is always going to be 0xFF
205 | dataView.setUint8(0, 0xFF);
206 | this.paletteABGR = palette.map(function(colorString) {
207 | var color = parseHexColor(colorString);
208 | dataView.setUint8(1, color.blue);
209 | dataView.setUint8(2, color.green);
210 | dataView.setUint8(3, color.red);
211 | return dataView.getUint32(0);
212 | });
213 |
214 | if (!isNew) {
215 | // TODO - clean up
216 | this.setInitialState(this.state);
217 | }
218 | },
219 |
220 | /**
221 | * Sets the cooldown time period.
222 | * After the cooldown has passed, the client is enabled.
223 | * The returned promise is also stored and reused in the whenCooldownEnds
224 | * method.
225 | * @function
226 | * @param {number} cooldownTime Duration of cooldown in ms
227 | * @returns {Promise} A promise that resolves when the cooldown ends.
228 | */
229 | setCooldownTime: function(cooldownTime) {
230 | var currentTime = Date.now();
231 | this.cooldownEndTime = currentTime + cooldownTime;
232 |
233 | var deferred = $.Deferred();
234 | setTimeout(function onTimeout() {
235 | this.enable();
236 | deferred.resolve();
237 | this.cooldownPromise = null;
238 | MollyGuard.showUnlocked();
239 | Timer.stopTimer();
240 | Timer.hide();
241 | }.bind(this), cooldownTime);
242 |
243 | if (cooldownTime) {
244 | Timer.startTimer(this.cooldownEndTime);
245 | Timer.show();
246 | }
247 |
248 | this.cooldownPromise = deferred.promise();
249 | if (cooldownTime) {
250 | MollyGuard.showLocked();
251 | }
252 |
253 | return this.cooldownPromise;
254 | },
255 |
256 | /**
257 | * Get a promise that resolves when the cooldown period expires.
258 | * If there isn't an active cooldown, returns a promise that
259 | * immediately resolves.
260 | *
261 | * Client.whenCooldownEnds().then(function() {
262 | * // do stuff
263 | * });
264 | *
265 | * @function
266 | * @returns {Promise}
267 | */
268 | whenCooldownEnds: function() {
269 | if (this.cooldownPromise) {
270 | return this.cooldownPromise;
271 | }
272 |
273 | var deferred = $.Deferred();
274 | deferred.resolve();
275 | return deferred.promise();
276 | },
277 |
278 | /**
279 | * Return the time remaining in the cooldown in ms
280 | * @function
281 | * @returns {number}
282 | */
283 | getCooldownTimeRemaining: function() {
284 | var currentTime = Date.now();
285 | var timeRemaining = this.cooldownEndTime - currentTime;
286 | return Math.max(0, timeRemaining);
287 | },
288 |
289 | /**
290 | * Tick function that updates interpolated zoom and offset values.
291 | * Not intended for external use.
292 | * @function
293 | * @returns {boolean} Returns true if anything updated.
294 | */
295 | tick: function() {
296 | var didUpdate = false;
297 | if (this._zoom !== this.zoom) {
298 | this._zoom = lerp(this._zoom, this.zoom, this.ZOOM_LERP_SPEED);
299 | Camera.updateScale(this._zoom);
300 | didUpdate = true;
301 | }
302 |
303 | this.currentDirection.x = 0;
304 | this.currentDirection.y = 0;
305 |
306 | if (Keyboard.isKeyDown('LEFT') || Keyboard.isKeyDown('A')) {
307 | this.currentDirection.x -= 1;
308 | }
309 | if (Keyboard.isKeyDown('RIGHT') || Keyboard.isKeyDown('D')) {
310 | this.currentDirection.x += 1;
311 | }
312 | if (Keyboard.isKeyDown('UP') || Keyboard.isKeyDown('W')) {
313 | this.currentDirection.y -= 1;
314 | }
315 | if (Keyboard.isKeyDown('DOWN') || Keyboard.isKeyDown('S')) {
316 | this.currentDirection.y += 1;
317 | }
318 |
319 | normalizeVector(this.currentDirection);
320 |
321 | if (this._currentDirection.x !== this.currentDirection.x) {
322 | this._currentDirection.x = lerp(this._currentDirection.x, this.currentDirection.x,
323 | this.KEYBOARD_PAN_LERP_SPEED);
324 | }
325 | if (this._currentDirection.y !== this.currentDirection.y) {
326 | this._currentDirection.y = lerp(this._currentDirection.y, this.currentDirection.y,
327 | this.KEYBOARD_PAN_LERP_SPEED);
328 | }
329 |
330 | var moveSpeed = this.ZOOM_MAX_SCALE / this._zoom * this.KEYBOARD_PAN_SPEED;
331 | this.panX -= this._currentDirection.x * moveSpeed;
332 | this.panY -= this._currentDirection.y * moveSpeed;
333 |
334 | var didOffsetUpdate = false;
335 | if (this._panX !== this.panX) {
336 | this._panX = lerp(this._panX, this.panX, this.PAN_LERP_SPEED);
337 | didOffsetUpdate = true;
338 | }
339 |
340 | if (this._panY !== this.panY) {
341 | this._panY = lerp(this._panY, this.panY, this.PAN_LERP_SPEED);
342 | didOffsetUpdate = true;
343 | }
344 |
345 | didUpdate = didUpdate || didOffsetUpdate;
346 |
347 | if (didOffsetUpdate) {
348 | Camera.updateTranslate(this._panX, this._panY);
349 | var coords = this.getCameraLocationFromOffset(this._panX, this._panY);
350 | Coordinates.setCoordinates(Math.round(coords.x), Math.round(coords.y));
351 | }
352 |
353 | return didUpdate;
354 | },
355 |
356 | /**
357 | * Get the css color string for the given colorIndex.
358 | * @function
359 | * @param {number} colorIndex The index of the color in the palette.
360 | * This is clamped into the 0 to MAX_COLOR_INDEX range. If the current
361 | * color palette has less colors than that defined, it repeats.
362 | * @returns {string}
363 | */
364 | getPaletteColor: function(colorIndex) {
365 | colorIndex = Math.min(MAX_COLOR_INDEX, Math.max(0, colorIndex|0));
366 | return this.palette[colorIndex % this.palette.length] || DEFAULT_COLOR;
367 | },
368 |
369 | getPaletteColorABGR: function(colorIndex) {
370 | colorIndex = Math.min(MAX_COLOR_INDEX, Math.max(0, colorIndex|0));
371 | return this.paletteABGR[colorIndex % this.paletteABGR.length] || DEFAULT_COLOR_ABGR;
372 | },
373 |
374 | /**
375 | * Sets the initial state of the canvas.
376 | * This accepts a Uint8Array of color indices
377 | * Note that if the API payload shape changes, this will need to update.
378 | * @function
379 | * @param {Uint8Array} state A Uint8Array of color indices
380 | */
381 | setInitialState: function(state) {
382 | // Iterate over API response state.
383 | var canvas = [];
384 |
385 | // Safari TypedArray implementation doesn't support forEach :weary:
386 | var colorIndex, color;
387 | for (var i = 0; i < state.length; i++) {
388 | colorIndex = state[i];
389 | color = this.getPaletteColorABGR(colorIndex);
390 | Canvasse.setBufferState(i, color);
391 | // Assumes that all non-0 values in local state are *newer* than the
392 | // state we're loading. This might not be strictly true but eh
393 | if (colorIndex > 0) {
394 | this.state[i] = colorIndex;
395 | }
396 | }
397 |
398 | Canvasse.drawBufferToDisplay();
399 | },
400 |
401 | /**
402 | * Update the current color
403 | * @function
404 | * @param {number} color Index of color in palette. Should be less than MAX_COLOR_INDEX
405 | * @param {boolean} [playSFX] Whether to play sound effects, defaults to true.
406 | * Useful for initializing with a color.
407 | */
408 | setColor: function(colorIndex, playSFX) {
409 | playSFX = playSFX === undefined ? true : playSFX;
410 | this.interact();
411 |
412 | if (!this.enabled) {
413 | if (playSFX) {
414 | AudioManager.playClip(SFX_ERROR);
415 | }
416 | return;
417 | }
418 |
419 | this.colorIndex = colorIndex;
420 | this.paletteColor = this.getPaletteColor(colorIndex);
421 | this.paletteColorABGR = this.getPaletteColorABGR(colorIndex);
422 | Hand.updateColor(this.paletteColor);
423 | if (this.isZoomedIn) {
424 | Hand.showCursor();
425 | }
426 | Palette.clearSwatchHighlights();
427 | Palette.highlightSwatch(colorIndex);
428 |
429 | if (playSFX) {
430 | AudioManager.playClip(SFX_SELECT);
431 | }
432 | },
433 |
434 | /**
435 | * Clear the current color
436 | * @function
437 | */
438 | clearColor: function(playSFX) {
439 | playSFX = playSFX === undefined ? true : playSFX;
440 |
441 | Hand.clearColor();
442 | Hand.hideCursor();
443 | Palette.clearSwatchHighlights();
444 | this.paletteColor = null;
445 | this.paletteColorABGR = null;
446 |
447 | if (playSFX) {
448 | AudioManager.playClip(SFX_DROP);
449 | }
450 | },
451 |
452 | /**
453 | * Returns whether or not the user is "holding" a color.
454 | * @returns {boolean}
455 | */
456 | hasColor: function() {
457 | return this.paletteColor !== null;
458 | },
459 |
460 | /**
461 | * Update the current zoom level.
462 | * Should be non-zero to avoid weirdness.
463 | * @function
464 | * @param {number} zoomLevel
465 | */
466 | setZoom: function(zoomLevel) {
467 | this._zoom = this.zoom = zoomLevel;
468 | this.isZoomedIn = zoomLevel === this.ZOOM_MAX_SCALE;
469 | if (this.isZoomedIn) {
470 | if (this.hasColor()) {
471 | Hand.showCursor();
472 | }
473 | } else {
474 | Hand.hideCursor();
475 | }
476 | Camera.updateScale(this._zoom);
477 | },
478 |
479 | /**
480 | * Update the current camera offsets.
481 | * Used to pan the camera around.
482 | * The x and y values are offsets for the canvas rather than camera
483 | * positions, which may be unintuitive to use. For example, to
484 | * position the camera in the top left corner of a 1000x1000 canvas,
485 | * you would call:
486 | *
487 | * r.place.setOffset(500, 500);
488 | *
489 | * which pushes the canvas down and to the right 500px, putting its
490 | * top left corner in the center of the screen. If this is confusing,
491 | * use the setCameraPosition method instead.
492 | * @function
493 | * @param {number} x
494 | * @param {number} y
495 | */
496 | setOffset: function(x, y) {
497 | this._panX = this.panX = x;
498 | this._panY = this.panY = y;
499 | Camera.updateTranslate(this._panX, this._panY);
500 | var coords = this.getCameraLocationFromOffset(this._panX, this._panY);
501 | Coordinates.setCoordinates(Math.round(coords.x), Math.round(coords.y));
502 | },
503 |
504 | /**
505 | * Given coordinates relative to the camera position, get canvas offsets.
506 | * See the setCameraPosition method description for more details.
507 | * @function
508 | * @param {number} x
509 | * @param {number} y
510 | */
511 | getOffsetFromCameraPosition: function(x, y) {
512 | return { x: -x, y: -y };
513 | },
514 |
515 | /**
516 | * Given an absolute canvas coordinat, get canvas offsets.
517 | * See the setCameraLocation method description for more details.
518 | * @function
519 | * @param {number} x
520 | * @param {number} y
521 | */
522 | getOffsetFromCameraLocation: function(x, y) {
523 | var size = this.getCanvasSize();
524 | return { x: -(x - size.width / 2), y: -(y - size.height / 2) };
525 | },
526 |
527 | /**
528 | * Given canvas offsets, get the camera coordinates.
529 | * The inverse of getOffsetFromCameraLocation.
530 | * @function
531 | * @param {number} x
532 | * @param {number} y
533 | */
534 | getCameraLocationFromOffset: function(x, y) {
535 | var size = this.getCanvasSize();
536 | return { x: size.width / 2 - x, y: size.height / 2 - y };
537 | },
538 |
539 | /**
540 | * Given the position in the container element, get the tile coordinate.
541 | * @function
542 | * @param {number} x
543 | * @param {number} y
544 | */
545 | getLocationFromCursorPosition: function(x, y) {
546 | var canvasSize = this.getCanvasSize();
547 | var containerSize = this.getContainerSize();
548 | return {
549 | x: Math.round(x / this.zoom + canvasSize.width / 2 - containerSize.width / (2 * this.zoom) - this.panX),
550 | y: Math.round(y / this.zoom + canvasSize.height / 2 - containerSize.height / (2 * this.zoom) - this.panY),
551 | };
552 | },
553 |
554 | /**
555 | * Given the location of the tile, give its position on screen
556 | * @function
557 | * @param {number} x
558 | * @param {number} y
559 | */
560 | getCursorPositionFromLocation: function(x, y) {
561 | var canvasSize = this.getCanvasSize();
562 | var containerSize = this.getContainerSize();
563 | return {
564 | x: this.zoom * (x - canvasSize.width / 2 + containerSize.width / (2 * this.zoom) + this.panX),
565 | y: this.zoom * (y - canvasSize.height / 2 + containerSize.height / (2 * this.zoom) + this.panY),
566 | };
567 | },
568 |
569 | /**
570 | * An alias for setOffset with values relative to the camera.
571 | * It literally just reverses the direction of the coordinates. To use
572 | * the above example, if you want to position the camera in the top left
573 | * corner using this method, you would call:
574 | *
575 | * r.place.setCameraPosition(-500, -500);
576 | *
577 | * which moves the camera up and to the left 500px, centering it on the
578 | * top left corner of the canvas.
579 | * @function
580 | * @param {number} x
581 | * @param {number} y
582 | */
583 | setCameraPosition: function(x, y) {
584 | var offsets = this.getOffsetFromCameraPosition(x, y);
585 | this.setOffset(offsets.x, offsets.y);
586 | },
587 |
588 | /**
589 | * The third and final option for setting the camera position.
590 | * This centers the camera on the given canvas coordinate. That is to
591 | * say, if you wanted to center the camera on the top left corner:
592 | *
593 | * r.place.setCameraLocation(0, 0);
594 | *
595 | * which moves the camera to the (0, 0) coordinate of the canvas.
596 | * @function
597 | * @param {number} x
598 | * @param {number} y
599 | */
600 | setCameraLocation: function(x, y) {
601 | var offsets = this.getOffsetFromCameraLocation(x, y);
602 | this.setOffset(offsets.x, offsets.y);
603 | },
604 |
605 | /**
606 | * Update the target zoom level for lerping
607 | * Should be non-zero to avoid weirdness.
608 | * @function
609 | * @param {number} zoomLevel
610 | */
611 | setTargetZoom: function(zoomLevel) {
612 | this.zoom = zoomLevel;
613 | this.isZoomedIn = zoomLevel === this.ZOOM_MAX_SCALE;
614 | },
615 |
616 | /**
617 | * Update the target camera offsets for lerping
618 | * Used to pan the camera around.
619 | * @function
620 | * @param {number} x
621 | * @param {number} y
622 | */
623 | setTargetOffset: function(x, y) {
624 | this.panX = x;
625 | this.panY = y;
626 | },
627 |
628 | /**
629 | * Update the target camera offsets relative to the camera.
630 | * @function
631 | * @param {number} x
632 | * @param {number} y
633 | */
634 | setTargetCameraPosition: function(x, y) {
635 | var offsets = this.getOffsetFromCameraPosition(x, y);
636 | this.setTargetOffset(offsets.x, offsets.y);
637 | },
638 |
639 | /**
640 | * Update the target camera offsets relative to the camera.
641 | * @function
642 | * @param {number} x
643 | * @param {number} y
644 | */
645 | setTargetCameraLocation: function(x, y) {
646 | var offsets = this.getOffsetFromCameraLocation(x, y);
647 | this.setTargetOffset(offsets.x, offsets.y);
648 | },
649 |
650 | /**
651 | * iOS Tile Notification management. Asks if the user desires notifications
652 | * if they select yes then attempt to register a local notification
653 | * using the webkit handler
654 | * @function
655 | */
656 | attemptToFireiOSLocalNotification: function() {
657 | if (parseInt(store.safeGet('iOS-Notifications-Enabled'), 10) === 1) {
658 | var isIOSFullscreen = (window.navigator.userAgent.indexOf('iPhone') > -1 && window.innerHeight > 200);
659 | if (isIOSFullscreen && typeof webkit !== 'undefined') {
660 | try {
661 | // tell iOS to setup a local notification
662 | webkit.messageHandlers.tilePlacedHandler.postMessage(this.cooldown / 1000);
663 | } catch(err) {
664 | // message handler doesn't exist for some reason
665 | }
666 | }
667 | }
668 | },
669 |
670 | /**
671 | * Draw the current color to the given coordinates
672 | * Makes the API call and optimistically updates the canvas.
673 | * @function
674 | * @param {number} x
675 | * @param {number} y
676 | */
677 | drawTile: function(x, y) {
678 | this.interact();
679 |
680 | if (!this.paletteColor || !this.enabled) {
681 | AudioManager.playClip(SFX_ERROR);
682 | return;
683 | }
684 |
685 | // Disable to prevent further draw actions until the API request resolves.
686 | this.disable();
687 |
688 | MollyGuard.showLocked();
689 | Timer.show();
690 | Timer.setText('Painting...');
691 | AudioManager.playClip(SFX_PLACE);
692 |
693 | var i = Canvasse.getIndexFromCoords(x, y);
694 | this.state[i] = this.colorIndex;
695 | Canvasse.drawTileAt(x, y, this.paletteColorABGR);
696 | this.clearColor(false);
697 |
698 | this.attemptToFireiOSLocalNotification();
699 |
700 | /*
701 | R2Server.draw(x, y, this.colorIndex)
702 | .then(function onSuccess(responseJSON, status, jqXHR) {
703 | return this.setCooldownTime(1000 * responseJSON.wait_seconds);
704 | }.bind(this))
705 | .fail(function onError(jqXHR, status, statusText) {
706 | this.enable();
707 | MollyGuard.showUnlocked();
708 | Timer.hide();
709 | }.bind(this))
710 | .then(function onSuccess() {
711 | Notifications.sendNotification('Your next tile is now available');
712 | })
713 | */
714 | },
715 |
716 | /**
717 | * Get info about the tile at the given coordinates.
718 | * @function
719 | * @param {number} x
720 | * @param {number} y
721 | */
722 | inspectTile: function(x, y) {
723 | this.interact();
724 |
725 | R2Server.getPixelInfo(x, y).then(
726 | // TODO - actually do something with this info in the UI.
727 | function onSuccess(responseJSON, status, jqXHR) {
728 | if ('color' in responseJSON) {
729 | this.setTargetCameraLocation(x, y);
730 | Inspector.show(
731 | responseJSON.x,
732 | responseJSON.y,
733 | responseJSON.user_name,
734 | responseJSON.timestamp
735 | );
736 | } else if (Inspector.isVisible) {
737 | Inspector.hide();
738 | }
739 | }.bind(this),
740 |
741 | function onError(jqXHR, status, statusText) {
742 | console.error(jqXHR);
743 | }.bind(this)
744 | )
745 | },
746 |
747 | /**
748 | * Toggles between the two predefined zoom levels.
749 | * @function
750 | * @param {number} [offsetX]
751 | * @param {number} [offsetY]
752 | */
753 | toggleZoom: function(offsetX, offsetY) {
754 | this.interact();
755 | if (this.isZoomedIn) {
756 | this.setTargetZoom(this.ZOOM_MIN_SCALE);
757 | AudioManager.playClip(SFX_ZOOM_OUT);
758 | ZoomButton.showZoomIn();
759 | Hand.hideCursor();
760 | } else {
761 | if (this.hasColor()) {
762 | Hand.showCursor();
763 | }
764 | this.setTargetZoom(this.ZOOM_MAX_SCALE);
765 | // Any time we are zooming in, also center camera where the user clicked
766 | if (offsetX !== undefined && offsetY !== undefined) {
767 | this.setTargetOffset(offsetX, offsetY);
768 | }
769 | AudioManager.playClip(SFX_ZOOM_IN);
770 | ZoomButton.showZoomOut();
771 | }
772 |
773 | this.isZoomedIn = this.zoom === this.ZOOM_MAX_SCALE;
774 | },
775 |
776 | /**
777 | * @typedef {Object} BoxSize
778 | * @property {number} width
779 | * @property {number} height
780 | */
781 |
782 | /**
783 | * Get the current canvas size.
784 | * @function
785 | * @returns {BoxSize}
786 | */
787 | getCanvasSize: function() {
788 | return {
789 | width: Canvasse.width,
790 | height: Canvasse.height,
791 | };
792 | },
793 |
794 | /**
795 | * Set the current container size.
796 | * @function
797 | * @param {number} width
798 | * @param {number} height
799 | */
800 | setContainerSize: function(width, height) {
801 | this.containerSize.width = width;
802 | this.containerSize.height = height;
803 | },
804 |
805 | /**
806 | * Get the current canvas size.
807 | * @function
808 | * @returns {BoxSize}
809 | */
810 | getContainerSize: function() {
811 | return this.containerSize;
812 | },
813 |
814 | /**
815 | * Disable the client. Intended for temporarily disabling for
816 | * handling ratelimiting, cooldowns, etc.
817 | * @function
818 | */
819 | disable: function() {
820 | this.enabled = false;
821 | },
822 |
823 | /**
824 | * Re-enable the client.
825 | * @function
826 | */
827 | enable: function() {
828 | this.enabled = true;
829 | },
830 |
831 | /**
832 | * Disable the client. Intended for temporarily disabling for
833 | * handling ratelimiting, cooldowns, etc.
834 | * @function
835 | */
836 | disablePan: function() {
837 | this.isPanEnabled = false;
838 | },
839 |
840 | /**
841 | * Re-enable the client.
842 | * @function
843 | */
844 | enablePan: function() {
845 | this.isPanEnabled = true;
846 | },
847 |
848 | injectHeaders: function(headers) {
849 | R2Server.injectHeaders(headers);
850 | var isIOSFullscreen = (window.navigator.userAgent.indexOf('iPhone') > -1 && window.innerHeight > 200);
851 | if (isIOSFullscreen) {
852 | NotificationButton.show();
853 | }
854 | },
855 |
856 | /**
857 | * Set the state of the AudioManager and MuteButton modules
858 | * For internal use
859 | * @function
860 | * @param {boolean} enabled Whether you are enabling or disabling audio.
861 | */
862 | _setAudioEnabled: function(enabled) {
863 | if (!AudioManager.isSupported) { return; }
864 | this.interact();
865 |
866 | if (enabled) {
867 | AudioManager.enable();
868 | MuteButton.showMute();
869 | store.remove('place-audio-isDisabled');
870 | } else {
871 | AudioManager.disable();
872 | MuteButton.showUnmute();
873 | store.safeSet('place-audio-isDisabled', '1');
874 | }
875 | },
876 |
877 | /**
878 | * Has the zoom button been acknowledged?
879 | * For internal use
880 | * @function
881 | */
882 | getZoomButtonClicked: function() {
883 | return parseInt(store.safeGet('place-zoom-wasClicked'), 10) || 0;
884 | },
885 |
886 | /**
887 | * Remember that zoom button has been acknowledged
888 | * @function
889 | */
890 | setZoomButtonClicked: function() {
891 | store.safeSet('place-zoom-wasClicked', '1');
892 | },
893 |
894 | /**
895 | * Toggle the volume on/off
896 | * @function
897 | */
898 | toggleVolume: function() {
899 | this._setAudioEnabled(!AudioManager.enabled);
900 | AudioManager.playClip(SFX_SELECT);
901 | },
902 |
903 | toggleNotificationButton: function() {
904 | var enabled = parseInt(store.safeGet('iOS-Notifications-Enabled'), 10) === 1;
905 | if (!enabled) {
906 | store.safeSet('iOS-Notifications-Enabled', '1');
907 | NotificationButton.showNotificationOn();
908 | } else {
909 | store.safeSet('iOS-Notifications-Enabled', '0');
910 | NotificationButton.showNotificationOff();
911 | }
912 | },
913 |
914 | /**
915 | * Sets the AudioManager volume globally.
916 | */
917 | setVolume: function(volume) {
918 | if (!AudioManager.isSupported) { return; }
919 |
920 | if (!volume) {
921 | this._setAudioEnabled(false);
922 | } else if (!AudioManager.globalVolume) {
923 | this._setAudioEnabled(true);
924 | }
925 |
926 | AudioManager.setGlobalVolume(volume);
927 | AudioManager.playClip(SFX_SELECT);
928 | },
929 |
930 | /**
931 | * Used to disable some features when the user interacts
932 | */
933 | interact: function() {
934 | this.disableAutoCamera();
935 | if (Inspector.isVisible) {
936 | Inspector.hide();
937 | }
938 | },
939 |
940 | /**
941 | * Method called by World when a tile update comes in.
942 | * @function
943 | * @param {number} x
944 | * @param {number} y
945 | */
946 | receiveTile: function(x, y) {
947 | this.trackRecentTile(x, y);
948 | if (!this.isWorldAudioEnabled) { return; }
949 | var camCoords = this.getCameraLocationFromOffset(this._panX, this._panY);
950 | var dist = Math.abs(getDistance(camCoords.x, camCoords.y, x, y));
951 | this.playTileSoundAtDistance(dist);
952 | },
953 |
954 | enableWorldAudio: function() {
955 | this.isWorldAudioEnabled = true;
956 | },
957 |
958 | disableWorldAudio: function() {
959 | this.isWorldAudioEnabled = false;
960 | },
961 |
962 | /**
963 | * Play the sound effect for placing a tile at the given distance
964 | * @function
965 | * @param {number} dist
966 | */
967 | playTileSoundAtDistance: function(dist) {
968 | if (dist > this.MAXIMUM_AUDIBLE_DISTANCE) { return; }
969 |
970 | var now = Date.now();
971 | if (now - this.lastWorldAudioTime < this.MAX_WORLD_AUDIO_RATE) { return; }
972 | this.lastWorldAudioTime = now;
973 |
974 | var globalVolume = AudioManager.globalVolume;
975 | var distanceMultiplier = clamp(0, 1, Math.pow(2, -dist/2));
976 | var volume = globalVolume * distanceMultiplier * this.WORLD_AUDIO_MULTIPLIER;
977 | AudioManager.playClip(SFX_PLACE, volume);
978 | },
979 |
980 | /**
981 | * Track the position of a recently added tile.
982 | * This is called by the world module and used to power the auto-camera
983 | * feature
984 | * @function
985 | * @param {number} x
986 | * @param {number} y
987 | */
988 | trackRecentTile: function(x, y) {
989 | // TODO - may be worth measuring the impact of doing this constantly,
990 | // and skip it when auto-camera is disabled (which is the default).
991 | if (recentTiles[recentTilesIndex]) {
992 | // recycle existing objects once the list is full
993 | recentTiles[recentTilesIndex].x = x;
994 | recentTiles[recentTilesIndex].y = y;
995 | } else {
996 | recentTiles[recentTilesIndex] = { x: x, y: y }
997 | }
998 | recentTilesIndex = (recentTilesIndex + 1) % maxrecentTilesLength;
999 | },
1000 |
1001 | /**
1002 | * Toggle the auto-camera feature
1003 | * @function
1004 | */
1005 | toggleAutoCamera: function() {
1006 | if (this.autoCameraEnabled) {
1007 | this.disableAutoCamera();
1008 | } else {
1009 | this.enableAutoCamera();
1010 | }
1011 | },
1012 |
1013 | /**
1014 | * Turn on the auto-camera feature.
1015 | * This will attempt to move the camera to a "hot spot" at a regular
1016 | * interval. It uses Rossmo's formula to identify 1 pixel out of the most
1017 | * recent n (currently 100) that is most likely to be the most interesting.
1018 | * The same formula can be used to find serial killers, or sharks. Neat!
1019 | * @function.
1020 | */
1021 | enableAutoCamera: function() {
1022 | if (this.autoCameraEnabled) { return }
1023 | this.autoCameraEnabled = true;
1024 | CameraButton.showDisable();
1025 |
1026 | autoCameraIntervalToken = setInterval(function() {
1027 | var maxScore = 0;
1028 | var winningIndex = 0;
1029 |
1030 | var tile, score;
1031 | for (var i = 0; i < recentTiles.length; i++) {
1032 | tile = recentTiles[i];
1033 | score = rossmoFormula(tile, recentTiles, B, k, f, g);
1034 | // TODO - we probably actually want to weight this by distance
1035 | // from current camera location, so that a smaller, but still
1036 | // significant activity nearby takes priority over a slightly bigger
1037 | // one farther away.
1038 | if (score > maxScore) {
1039 | maxScore = score;
1040 | winningIndex = i;
1041 | }
1042 | }
1043 |
1044 | if (tile) {
1045 | this.setTargetCameraLocation(tile.x, tile.y);
1046 | }
1047 | }.bind(this), this.AUTOCAMERA_INTERVAL);
1048 | },
1049 |
1050 | /**
1051 | * Turn of the auto-camera feature.
1052 | */
1053 | disableAutoCamera: function() {
1054 | if (!this.autoCameraEnabled) { return }
1055 | this.autoCameraEnabled = false;
1056 | CameraButton.showEnable();
1057 |
1058 | clearInterval(autoCameraIntervalToken);
1059 | },
1060 |
1061 | /**
1062 | * Truncate the list of recent pixels used by the autoCamera feature.
1063 | */
1064 | clearrecentTiles: function() {
1065 | recentTiles.length = 0;
1066 | recentTilesIndex = 0;
1067 | },
1068 | };
1069 | });
1070 |
--------------------------------------------------------------------------------