UNPKG

goban

Version:

[![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/online-go/goban)

1,287 lines (1,231 loc) 941 kB
/*! * Copyright (C) Online-Go.com * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ (function webpackUniversalModuleDefinition(root, factory) { if(typeof exports === 'object' && typeof module === 'object') module.exports = factory(); else if(typeof define === 'function' && define.amd) define([], factory); else if(typeof exports === 'object') exports["goban"] = factory(); else root["goban"] = factory(); })(self, () => { return /******/ (() => { // webpackBootstrap /******/ "use strict"; /******/ var __webpack_modules__ = ({ /***/ "./node_modules/eventemitter3/index.js": /*!*********************************************!*\ !*** ./node_modules/eventemitter3/index.js ***! \*********************************************/ /***/ ((module) => { var has = Object.prototype.hasOwnProperty , prefix = '~'; /** * Constructor to create a storage for our `EE` objects. * An `Events` instance is a plain object whose properties are event names. * * @constructor * @private */ function Events() {} // // We try to not inherit from `Object.prototype`. In some engines creating an // instance in this way is faster than calling `Object.create(null)` directly. // If `Object.create(null)` is not supported we prefix the event names with a // character to make sure that the built-in object properties are not // overridden or used as an attack vector. // if (Object.create) { Events.prototype = Object.create(null); // // This hack is needed because the `__proto__` property is still inherited in // some old browsers like Android 4, iPhone 5.1, Opera 11 and Safari 5. // if (!new Events().__proto__) prefix = false; } /** * Representation of a single event listener. * * @param {Function} fn The listener function. * @param {*} context The context to invoke the listener with. * @param {Boolean} [once=false] Specify if the listener is a one-time listener. * @constructor * @private */ function EE(fn, context, once) { this.fn = fn; this.context = context; this.once = once || false; } /** * Add a listener for a given event. * * @param {EventEmitter} emitter Reference to the `EventEmitter` instance. * @param {(String|Symbol)} event The event name. * @param {Function} fn The listener function. * @param {*} context The context to invoke the listener with. * @param {Boolean} once Specify if the listener is a one-time listener. * @returns {EventEmitter} * @private */ function addListener(emitter, event, fn, context, once) { if (typeof fn !== 'function') { throw new TypeError('The listener must be a function'); } var listener = new EE(fn, context || emitter, once) , evt = prefix ? prefix + event : event; if (!emitter._events[evt]) emitter._events[evt] = listener, emitter._eventsCount++; else if (!emitter._events[evt].fn) emitter._events[evt].push(listener); else emitter._events[evt] = [emitter._events[evt], listener]; return emitter; } /** * Clear event by name. * * @param {EventEmitter} emitter Reference to the `EventEmitter` instance. * @param {(String|Symbol)} evt The Event name. * @private */ function clearEvent(emitter, evt) { if (--emitter._eventsCount === 0) emitter._events = new Events(); else delete emitter._events[evt]; } /** * Minimal `EventEmitter` interface that is molded against the Node.js * `EventEmitter` interface. * * @constructor * @public */ function EventEmitter() { this._events = new Events(); this._eventsCount = 0; } /** * Return an array listing the events for which the emitter has registered * listeners. * * @returns {Array} * @public */ EventEmitter.prototype.eventNames = function eventNames() { var names = [] , events , name; if (this._eventsCount === 0) return names; for (name in (events = this._events)) { if (has.call(events, name)) names.push(prefix ? name.slice(1) : name); } if (Object.getOwnPropertySymbols) { return names.concat(Object.getOwnPropertySymbols(events)); } return names; }; /** * Return the listeners registered for a given event. * * @param {(String|Symbol)} event The event name. * @returns {Array} The registered listeners. * @public */ EventEmitter.prototype.listeners = function listeners(event) { var evt = prefix ? prefix + event : event , handlers = this._events[evt]; if (!handlers) return []; if (handlers.fn) return [handlers.fn]; for (var i = 0, l = handlers.length, ee = new Array(l); i < l; i++) { ee[i] = handlers[i].fn; } return ee; }; /** * Return the number of listeners listening to a given event. * * @param {(String|Symbol)} event The event name. * @returns {Number} The number of listeners. * @public */ EventEmitter.prototype.listenerCount = function listenerCount(event) { var evt = prefix ? prefix + event : event , listeners = this._events[evt]; if (!listeners) return 0; if (listeners.fn) return 1; return listeners.length; }; /** * Calls each of the listeners registered for a given event. * * @param {(String|Symbol)} event The event name. * @returns {Boolean} `true` if the event had listeners, else `false`. * @public */ EventEmitter.prototype.emit = function emit(event, a1, a2, a3, a4, a5) { var evt = prefix ? prefix + event : event; if (!this._events[evt]) return false; var listeners = this._events[evt] , len = arguments.length , args , i; if (listeners.fn) { if (listeners.once) this.removeListener(event, listeners.fn, undefined, true); switch (len) { case 1: return listeners.fn.call(listeners.context), true; case 2: return listeners.fn.call(listeners.context, a1), true; case 3: return listeners.fn.call(listeners.context, a1, a2), true; case 4: return listeners.fn.call(listeners.context, a1, a2, a3), true; case 5: return listeners.fn.call(listeners.context, a1, a2, a3, a4), true; case 6: return listeners.fn.call(listeners.context, a1, a2, a3, a4, a5), true; } for (i = 1, args = new Array(len -1); i < len; i++) { args[i - 1] = arguments[i]; } listeners.fn.apply(listeners.context, args); } else { var length = listeners.length , j; for (i = 0; i < length; i++) { if (listeners[i].once) this.removeListener(event, listeners[i].fn, undefined, true); switch (len) { case 1: listeners[i].fn.call(listeners[i].context); break; case 2: listeners[i].fn.call(listeners[i].context, a1); break; case 3: listeners[i].fn.call(listeners[i].context, a1, a2); break; case 4: listeners[i].fn.call(listeners[i].context, a1, a2, a3); break; default: if (!args) for (j = 1, args = new Array(len -1); j < len; j++) { args[j - 1] = arguments[j]; } listeners[i].fn.apply(listeners[i].context, args); } } } return true; }; /** * Add a listener for a given event. * * @param {(String|Symbol)} event The event name. * @param {Function} fn The listener function. * @param {*} [context=this] The context to invoke the listener with. * @returns {EventEmitter} `this`. * @public */ EventEmitter.prototype.on = function on(event, fn, context) { return addListener(this, event, fn, context, false); }; /** * Add a one-time listener for a given event. * * @param {(String|Symbol)} event The event name. * @param {Function} fn The listener function. * @param {*} [context=this] The context to invoke the listener with. * @returns {EventEmitter} `this`. * @public */ EventEmitter.prototype.once = function once(event, fn, context) { return addListener(this, event, fn, context, true); }; /** * Remove the listeners of a given event. * * @param {(String|Symbol)} event The event name. * @param {Function} fn Only remove the listeners that match this function. * @param {*} context Only remove the listeners that have this context. * @param {Boolean} once Only remove one-time listeners. * @returns {EventEmitter} `this`. * @public */ EventEmitter.prototype.removeListener = function removeListener(event, fn, context, once) { var evt = prefix ? prefix + event : event; if (!this._events[evt]) return this; if (!fn) { clearEvent(this, evt); return this; } var listeners = this._events[evt]; if (listeners.fn) { if ( listeners.fn === fn && (!once || listeners.once) && (!context || listeners.context === context) ) { clearEvent(this, evt); } } else { for (var i = 0, events = [], length = listeners.length; i < length; i++) { if ( listeners[i].fn !== fn || (once && !listeners[i].once) || (context && listeners[i].context !== context) ) { events.push(listeners[i]); } } // // Reset the array, or remove it completely if we have no more listeners. // if (events.length) this._events[evt] = events.length === 1 ? events[0] : events; else clearEvent(this, evt); } return this; }; /** * Remove all listeners, or those of the specified event. * * @param {(String|Symbol)} [event] The event name. * @returns {EventEmitter} `this`. * @public */ EventEmitter.prototype.removeAllListeners = function removeAllListeners(event) { var evt; if (event) { evt = prefix ? prefix + event : event; if (this._events[evt]) clearEvent(this, evt); } else { this._events = new Events(); this._eventsCount = 0; } return this; }; // // Alias methods names because people roll like that. // EventEmitter.prototype.off = EventEmitter.prototype.removeListener; EventEmitter.prototype.addListener = EventEmitter.prototype.on; // // Expose the prefix. // EventEmitter.prefixed = prefix; // // Allow `EventEmitter` to be imported as module namespace. // EventEmitter.EventEmitter = EventEmitter; // // Expose the module. // if (true) { module.exports = EventEmitter; } /***/ }), /***/ "./src/Goban/CanvasRenderer.ts": /*!*************************************!*\ !*** ./src/Goban/CanvasRenderer.ts ***! \*************************************/ /***/ ((__unused_webpack_module, exports, __webpack_require__) => { /* * Copyright (C) Online-Go.com * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.GobanCanvas = void 0; const JGOF_1 = __webpack_require__(/*! ../engine/formats/JGOF */ "./src/engine/formats/JGOF.ts"); const MoveTree_1 = __webpack_require__(/*! ../engine/MoveTree */ "./src/engine/MoveTree.ts"); const themes_1 = __webpack_require__(/*! ./themes */ "./src/Goban/themes/index.ts"); const canvas_utils_1 = __webpack_require__(/*! ./canvas_utils */ "./src/Goban/canvas_utils.ts"); const translate_1 = __webpack_require__(/*! ../engine/translate */ "./src/engine/translate.ts"); const messages_1 = __webpack_require__(/*! ../engine/messages */ "./src/engine/messages.ts"); const util_1 = __webpack_require__(/*! ../engine/util */ "./src/engine/util/index.ts"); const callbacks_1 = __webpack_require__(/*! ./callbacks */ "./src/Goban/callbacks.ts"); const Goban_1 = __webpack_require__(/*! ./Goban */ "./src/Goban/Goban.ts"); const __theme_cache = { black: {}, white: {}, }; const HOT_PINK = "#ff69b4"; class GobanCanvas extends Goban_1.Goban { constructor(config, preloaded_data) { var _a; /* TODO: Need to reconcile the clock fields before we can get rid of this `any` cast */ super(config, preloaded_data); this.__set_board_height = -1; this.__set_board_width = -1; this.ready_to_draw = false; this.last_move_opacity = 1; this.__borders_initialized = false; this.autoplaying_puzzle_move = false; this.byoyomi_label = ""; this.last_label_position = { i: NaN, j: NaN }; this.metrics = { width: NaN, height: NaN, mid: NaN, offset: NaN }; this.drawing_enabled = true; this.themes = { "board": "Plain", "black": "Plain", "white": "Plain", "removal-graphic": "x", "removal-scale": 1.0, }; this.theme_black_stones = []; this.theme_black_text_color = HOT_PINK; this.theme_blank_text_color = HOT_PINK; this.theme_faded_line_color = HOT_PINK; this.theme_faded_star_color = HOT_PINK; //private theme_faded_text_color:string; this.theme_line_color = ""; this.theme_star_color = ""; this.theme_stone_radius = 10; this.theme_white_stones = []; this.theme_white_text_color = HOT_PINK; // console.log("Goban canvas v 0.5.74.debug 5"); // GaJ: I use this to be sure I have linked & loaded the updates if (config.board_div) { this.parent = config["board_div"]; } else { this.no_display = true; this.parent = document.createElement("div"); /* let a div dangle in no-mans land to prevent null pointer refs */ } this.title_div = config["title_div"]; this.board = (0, canvas_utils_1.createDeviceScaledCanvas)(10, 10); this.board.setAttribute("id", "board-canvas"); this.board.className = "StoneLayer"; this.last_move_opacity = (_a = config["last_move_opacity"]) !== null && _a !== void 0 ? _a : 1; const ctx = this.board.getContext("2d", { willReadFrequently: true }); if (ctx) { this.ctx = ctx; } else { throw new Error(`Failed to obtain drawing context for board`); } this.parent.appendChild(this.board); this.bindPointerBindings(this.board); this.move_tree_container = config.move_tree_container; this.handleShiftKey = (ev) => { try { if (ev.shiftKey !== this.shift_key_is_down) { this.shift_key_is_down = ev.shiftKey; if (this.last_hover_square) { this.__drawSquare(this.last_hover_square.x, this.last_hover_square.y); } } } catch (e) { console.error(e); } }; window.addEventListener("keydown", this.handleShiftKey); window.addEventListener("keyup", this.handleShiftKey); // these are set in this.setThemes(..) // this.theme_board // this.theme_white // this.theme_black this.setTheme(this.getSelectedThemes(), true); let first_pass = true; const watcher = this.watchSelectedThemes((themes) => { var _a, _b, _c; if (!this.engine) { return; } if (themes.black === "Custom" || themes.white === "Custom" || themes.board === "Custom") { //first_pass = true; } (_a = __theme_cache.black) === null || _a === void 0 ? true : delete _a["Custom"]; (_b = __theme_cache.white) === null || _b === void 0 ? true : delete _b["Custom"]; (_c = __theme_cache.board) === null || _c === void 0 ? true : delete _c["Custom"]; this.setTheme(themes, first_pass ? true : false); first_pass = false; }); this.on("destroy", () => watcher.remove()); this.engine = this.post_config_constructor(); this.emit("engine.updated", this.engine); this.ready_to_draw = true; this.redraw(true); } setLastMoveOpacity(opacity) { this.last_move_opacity = opacity; } enablePen() { this.attachPenCanvas(); } disablePen() { this.detachPenCanvas(); } destroy() { super.destroy(); if (this.board && this.board.parentNode) { this.board.parentNode.removeChild(this.board); } delete this.board; delete this.ctx; this.detachPenCanvas(); this.detachShadowLayer(); if (this.message_timeout) { clearTimeout(this.message_timeout); delete this.message_timeout; } window.removeEventListener("keydown", this.handleShiftKey); window.removeEventListener("keyup", this.handleShiftKey); this.theme_black_stones = []; this.theme_white_stones = []; delete this.theme_board; delete this.theme_black; delete this.theme_white; delete this.message_div; delete this.message_td; delete this.message_text; delete this.move_tree_container; delete this.move_tree_inner_container; delete this.move_tree_canvas; delete this.title_div; } detachShadowLayer() { if (this.shadow_layer) { if (this.shadow_layer.parentNode) { this.shadow_layer.parentNode.removeChild(this.shadow_layer); } delete this.shadow_layer; delete this.shadow_ctx; } } attachShadowLayer() { if (!this.shadow_layer && this.parent) { this.shadow_layer = (0, canvas_utils_1.createDeviceScaledCanvas)(this.metrics.width, this.metrics.height); this.shadow_layer.setAttribute("id", "shadow-canvas"); this.shadow_layer.className = "ShadowLayer"; try { this.parent.insertBefore(this.shadow_layer, this.board); } catch (e) { // I'm not really sure how we ever get into this state, but sentry.io reports that we do console.warn("Error inserting shadow layer before board"); console.warn(e); try { this.parent.appendChild(this.shadow_layer); } catch (e) { console.error(e); } } const ctx = this.shadow_layer.getContext("2d", { willReadFrequently: true }); if (ctx) { this.shadow_ctx = ctx; } else { //throw new Error(`Failed to obtain shadow layer drawing context`); console.error(new Error(`Failed to obtain shadow layer drawing context`)); return; } this.bindPointerBindings(this.shadow_layer); } } detachPenCanvas() { if (this.pen_layer) { if (this.pen_layer.parentNode) { this.pen_layer.parentNode.removeChild(this.pen_layer); } delete this.pen_layer; delete this.pen_ctx; } } attachPenCanvas() { if (!this.pen_layer) { this.pen_layer = (0, canvas_utils_1.createDeviceScaledCanvas)(this.metrics.width, this.metrics.height); this.pen_layer.setAttribute("id", "pen-canvas"); this.pen_layer.className = "PenLayer"; this.parent.appendChild(this.pen_layer); const ctx = this.pen_layer.getContext("2d", { willReadFrequently: true }); if (ctx) { this.pen_ctx = ctx; } else { throw new Error(`Failed to obtain pen drawing context`); } this.bindPointerBindings(this.pen_layer); } } bindPointerBindings(canvas) { if (!this.interactive) { return; } if (canvas.getAttribute("data-pointers-bound") === "true") { return; } canvas.setAttribute("data-pointers-bound", "true"); let dragging = false; let last_click_square = this.xy2ij(0, 0); let pointer_down_timestamp = 0; const pointerUp = (ev, double_clicked) => { const press_duration_ms = performance.now() - pointer_down_timestamp; try { if (!dragging) { /* if we didn't start the click in the canvas, don't respond to it */ return; } let right_click = false; if (ev instanceof MouseEvent) { if (ev.button === 2) { right_click = true; ev.preventDefault(); } } dragging = false; if (this.scoring_mode) { const pos = (0, canvas_utils_1.getRelativeEventPosition)(ev); const pt = this.xy2ij(pos.x, pos.y); if (pt.i >= 0 && pt.i < this.width && pt.j >= 0 && pt.j < this.height) { if (this.score_estimator) { this.score_estimator.handleClick(pt.i, pt.j, ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey, press_duration_ms); } this.emit("update"); } return; } if (ev.ctrlKey || ev.metaKey || ev.altKey) { try { const pos = (0, canvas_utils_1.getRelativeEventPosition)(ev); const pt = this.xy2ij(pos.x, pos.y); if (callbacks_1.callbacks.addCoordinatesToChatInput) { callbacks_1.callbacks.addCoordinatesToChatInput(this.engine.prettyCoordinates(pt.i, pt.j)); } } catch (e) { console.error(e); } return; } if (this.mode === "analyze" && this.analyze_tool === "draw") { /* might want to interpret this as a start/stop of a line segment */ } else if (this.mode === "analyze" && this.analyze_tool === "score") { // nothing to do here } else if (this.mode === "analyze" && this.analyze_tool === "removal") { this.onAnalysisToggleStoneRemoval(ev); } else { const pos = (0, canvas_utils_1.getRelativeEventPosition)(ev); const pt = this.xy2ij(pos.x, pos.y); if (!double_clicked) { last_click_square = pt; } else { if (last_click_square.i !== pt.i || last_click_square.j !== pt.j) { this.onMouseOut(ev); return; } } this.onTap(ev, double_clicked, right_click, press_duration_ms); this.onMouseOut(ev); } } catch (e) { console.error(e); } }; const pointerDown = (ev) => { pointer_down_timestamp = performance.now(); try { dragging = true; if (this.mode === "analyze" && this.analyze_tool === "draw") { this.onPenStart(ev); } else if (this.mode === "analyze" && this.analyze_tool === "label") { if (ev.shiftKey) { if (this.analyze_subtool === "letters") { const label_char = prompt((0, translate_1._)("Enter the label you want to add to the board"), ""); if (label_char) { this.label_character = label_char.substring(0, 3); dragging = false; return; } } } this.onLabelingStart(ev); } else if (this.mode === "analyze" && this.analyze_tool === "score") { this.onAnalysisScoringStart(ev); } else if (this.mode === "analyze" && this.analyze_tool === "removal") { // nothing to do here, we act on pointerUp } } catch (e) { console.error(e); } }; const pointerMove = (ev) => { try { if (this.mode === "analyze" && this.analyze_tool === "draw") { if (!dragging) { return; } this.onPenMove(ev); } else if (dragging && this.mode === "analyze" && this.analyze_tool === "label") { this.onLabelingMove(ev); } else if (dragging && this.mode === "analyze" && this.analyze_tool === "score") { this.onAnalysisScoringMove(ev); } else if (dragging && this.mode === "analyze" && this.analyze_tool === "removal") { // nothing for moving } else { this.onMouseMove(ev); } } catch (e) { console.error(e); } }; const pointerOut = (ev) => { try { dragging = false; this.onMouseOut(ev); } catch (e) { console.error(e); } }; let mouse_disabled = 0; canvas.addEventListener("click", (ev) => { if (!mouse_disabled) { dragging = true; pointerUp(ev, false); } ev.preventDefault(); return false; }); canvas.addEventListener("dblclick", (ev) => { if (!mouse_disabled) { dragging = true; pointerUp(ev, true); } ev.preventDefault(); return false; }); canvas.addEventListener("mousedown", (ev) => { if (!mouse_disabled) { pointerDown(ev); } ev.preventDefault(); return false; }); canvas.addEventListener("mousemove", (ev) => { if (!mouse_disabled) { pointerMove(ev); } ev.preventDefault(); return false; }); canvas.addEventListener("mouseout", (ev) => { if (!mouse_disabled) { pointerOut(ev); } else { ev.preventDefault(); } return false; }); canvas.addEventListener("contextmenu", (ev) => { if (!mouse_disabled) { pointerUp(ev, false); } else { ev.preventDefault(); } return false; }); canvas.addEventListener("focus", (ev) => { ev.preventDefault(); return false; }); let lastX = 0; let lastY = 0; let startX = 0; let startY = 0; const onTouchStart = (ev) => { try { if (mouse_disabled) { clearTimeout(mouse_disabled); } mouse_disabled = setTimeout(() => { mouse_disabled = 0; }, 5000); (0, canvas_utils_1.getRelativeEventPosition)(ev); // enables tracking of last ev position so on touch end can always tell where we released from if (ev.target === canvas) { lastX = ev.touches[0].clientX; lastY = ev.touches[0].clientY; startX = ev.touches[0].clientX; startY = ev.touches[0].clientY; pointerDown(ev); } else if (dragging) { pointerOut(ev); } } catch (e) { console.error(e); } }; const onTouchEnd = (ev) => { try { // Stop a touch screen device always auto scrolling to the chat input box if it is active when you make a move const currentElement = document.activeElement; if (ev.target === canvas && currentElement && currentElement instanceof HTMLElement && currentElement.tagName.toLowerCase() === "input") { currentElement.blur(); } if (mouse_disabled) { clearTimeout(mouse_disabled); } mouse_disabled = setTimeout(() => { mouse_disabled = 0; }, 5000); if (ev.target === canvas) { if (Math.sqrt((startX - lastX) * (startX - lastX) + (startY - lastY) * (startY - lastY)) > 10) { pointerOut(ev); } else { pointerUp(ev, false); } } else if (dragging) { pointerOut(ev); } } catch (e) { console.error(e); } }; const onTouchMove = (ev) => { try { if (mouse_disabled) { clearTimeout(mouse_disabled); } mouse_disabled = setTimeout(() => { mouse_disabled = 0; }, 5000); (0, canvas_utils_1.getRelativeEventPosition)(ev); // enables tracking of last ev position so on touch end can always tell where we released from if (ev.target === canvas) { lastX = ev.touches[0].clientX; lastY = ev.touches[0].clientY; if (this.mode === "analyze" && this.analyze_tool === "draw") { pointerMove(ev); ev.preventDefault(); return false; } } else if (dragging) { pointerOut(ev); } } catch (e) { console.error(e); } return undefined; }; document.addEventListener("touchstart", onTouchStart); document.addEventListener("touchend", onTouchEnd); document.addEventListener("touchmove", onTouchMove); const cleanup = () => { document.removeEventListener("touchstart", onTouchStart); document.removeEventListener("touchend", onTouchEnd); document.removeEventListener("touchmove", onTouchMove); this.off("destroy", cleanup); }; this.on("destroy", cleanup); } clearAnalysisDrawing() { this.pen_marks = []; if (this.pen_ctx) { this.pen_ctx.clearRect(0, 0, this.metrics.width, this.metrics.height); } } xy2pen(x, y) { const lx = this.draw_left_labels ? 0.0 : 1.0; const ly = this.draw_top_labels ? 0.0 : 1.0; return [ Math.round((x / this.square_size + lx) * 64), Math.round((y / this.square_size + ly) * 64), ]; } pen2xy(x, y) { const lx = this.draw_left_labels ? 0.0 : 1.0; const ly = this.draw_top_labels ? 0.0 : 1.0; return [(x / 64 - lx) * this.square_size, (y / 64 - ly) * this.square_size]; } setPenStyle(color) { if (!this.pen_ctx) { throw new Error(`setPenStyle called with null pen_ctx`); } this.pen_ctx.strokeStyle = color; this.pen_ctx.lineWidth = Math.max(1, Math.round(this.square_size * 0.1)); this.pen_ctx.lineCap = "round"; } onPenStart(ev) { this.attachPenCanvas(); const pos = (0, canvas_utils_1.getRelativeEventPosition)(ev); this.last_pen_position = this.xy2pen(pos.x, pos.y); this.current_pen_mark = { color: this.analyze_subtool, points: this.xy2pen(pos.x, pos.y) }; this.pen_marks.push(this.current_pen_mark); this.setPenStyle(this.analyze_subtool); this.syncReviewMove({ pen: this.analyze_subtool, pp: this.xy2pen(pos.x, pos.y) }); } onPenMove(ev) { if (!this.pen_ctx) { throw new Error(`onPenMove called with null pen_ctx`); } if (!this.last_pen_position || !this.current_pen_mark) { throw new Error(`onPenMove called with invalid last pen position or current pen mark`); } const pos = (0, canvas_utils_1.getRelativeEventPosition)(ev); const start = this.last_pen_position; const s = this.pen2xy(start[0], start[1]); const end = this.xy2pen(pos.x, pos.y); const e = this.pen2xy(end[0], end[1]); const dx = end[0] - start[0]; const dy = end[1] - start[1]; if (dx * dx + dy * dy < 64) { return; } this.last_pen_position = end; this.current_pen_mark.points.push(dx); this.current_pen_mark.points.push(dy); this.pen_ctx.beginPath(); this.pen_ctx.moveTo(s[0], s[1]); this.pen_ctx.lineTo(e[0], e[1]); this.pen_ctx.stroke(); this.syncReviewMove({ pp: [dx, dy] }); } drawPenMarks(pen_marks) { if (this.review_id && !this.done_loading_review) { return; } if (!pen_marks.length) { return; } this.attachPenCanvas(); if (!this.pen_ctx) { throw new Error(`onPenMove called with null pen_ctx`); } this.clearAnalysisDrawing(); this.pen_marks = pen_marks; for (let i = 0; i < pen_marks.length; ++i) { const stroke = pen_marks[i]; this.setPenStyle(stroke.color); let px = stroke.points[0]; let py = stroke.points[1]; this.pen_ctx.beginPath(); const pt = this.pen2xy(px, py); this.pen_ctx.moveTo(pt[0], pt[1]); for (let j = 2; j < stroke.points.length; j += 2) { px += stroke.points[j]; py += stroke.points[j + 1]; const pt = this.pen2xy(px, py); this.pen_ctx.lineTo(pt[0], pt[1]); } this.pen_ctx.stroke(); } } onTap(event, double_tap, right_click, press_duration_ms) { if (!(this.stone_placement_enabled && (this.player_id || !this.engine.players.black.id || this.mode === "analyze" || this.mode === "puzzle"))) { return; } // If there are modes where right click should not behave as if you clicked a placement on the canvas // then return here instead of proceeding. if (right_click && this.mode === "play") { return; } const pos = (0, canvas_utils_1.getRelativeEventPosition)(event); const xx = pos.x; const yy = pos.y; const pt = this.xy2ij(xx, yy); const x = pt.i; const y = pt.j; if (x < 0 || y < 0 || x >= this.engine.width || y >= this.engine.height) { return; } if (!this.double_click_submit) { double_tap = false; } if (this.mode === "analyze" && event.shiftKey && /* don't warp to move tree position when shift clicking in stone edit mode */ !(this.analyze_tool === "stone" && (this.analyze_subtool === "black" || this.analyze_subtool === "white")) && /* nor when in labeling mode */ this.analyze_tool !== "label") { const m = this.engine.getMoveByLocation(x, y, true); if (m) { this.engine.jumpTo(m); this.emit("update"); } return; } if (this.mode === "analyze" && this.analyze_tool === "label") { return; } this.setSubmit(undefined); const tap_time = Date.now(); let removed_count = 0; const removed_stones = []; const submit = () => { const submit_time = Date.now(); if (!this.one_click_submit && (!this.double_click_submit || !double_tap)) { /* then submit button was pressed, so check to make sure this didn't happen too quick */ const delta = submit_time - tap_time; if (delta <= 50) { console.info("Submit button pressed only ", delta, "ms after stone was placed, presuming bad click"); return; } } const sent = this.sendMove({ game_id: this.game_id, move: (0, util_1.encodeMove)(x, y), }); if (sent) { this.playMovementSound(); this.setTitle((0, translate_1._)("Submitting...")); if (removed_count) { this.debouncedEmitCapturedStones(removed_stones); } this.disableStonePlacement(); delete this.move_selected; } else { console.log("Move not sent, not playing movement sound"); } }; /* we disable clicking if we've been initialized with the view user, * unless the board is a demo board (thus black_player_id is 0). */ try { let force_redraw = false; if (this.engine.phase === "stone removal" && this.engine.isActivePlayer(this.player_id) && this.engine.cur_move === this.engine.last_official_move) { const { removed, group } = this.engine.toggleSingleGroupRemoval(x, y, event.shiftKey || press_duration_ms > 500); if (group.length) { this.socket.send("game/removed_stones/set", { game_id: this.game_id, removed: removed, stones: (0, util_1.encodeMoves)(group), }); } } else if (this.mode === "puzzle") { let puzzle_mode = "place"; let color = 0; if (this.getPuzzlePlacementSetting) { const s = this.getPuzzlePlacementSetting(); puzzle_mode = s.mode; if (s.mode === "setup") { color = s.color; if (this.shift_key_is_down || right_click) { color = color === 1 ? 2 : 1; } } } if (puzzle_mode === "place") { if (!double_tap) { /* we get called for each tap, then once for the final double tap so we only want to process this x2 */ this.engine.place(x, y, true, false, true, false, false); this.emit("puzzle-place", { x, y, width: this.engine.width, height: this.engine.height, color: this.engine.colorToMove(), }); } } if (puzzle_mode === "play") { /* we get called for each tap, then once for the final double tap so we only want to process this x2 */ /* Also, if we just placed a piece and the computer is waiting to place it's piece (autoplaying), then * don't allow anything to be placed. */ if (!double_tap && !this.autoplaying_puzzle_move) { let calls = 0; if (this.engine.puzzle_player_move_mode !== "fixed" || this.engine.cur_move.lookupMove(x, y, this.engine.player, false)) { const puzzle_place = (mv_x, mv_y) => { ++calls; removed_count = this.engine.place(mv_x, mv_y, true, false, true, false, false, removed_stones); this.emit("puzzle-place", { x: mv_x, y: mv_y, width: this.engine.width, height: this.engine.height, color: this.engine.colorToMove(), }); if (this.engine.cur_move.wrong_answer) { this.emit("puzzle-wrong-answer"); } if (this.engine.cur_move.correct_answer) { this.emit("puzzle-correct-answer"); } if (this.engine.cur_move.branches.length === 0) { const isobranches = this.engine.cur_move.findStrongIsobranches(); if (isobranches.length > 0) { const w = (0, util_1.getRandomInt)(0, isobranches.length); const which = isobranches[w]; console.info("Following isomorphism (" + (w + 1) + " of " + isobranches.length + ")"); this.engine.jumpTo(which); this.emit("update"); } } if (this.engine.cur_move.branches.length) { const next = this.engine.cur_move.branches[(0, util_1.getRandomInt)(0, this.engine.cur_move.branches.length)]; if (calls === 1 && /* only move if it's the "ai" turn.. if we undo we can get into states where we * are playing for the ai for some moves so don't auto-move blindly */ ((next.player === 2 && this.engine.config.initial_player === "black") || (next.player === 1 && this.engine.config.initial_player === "white")) && this.engine.puzzle_opponent_move_mode !== "manual") { this.autoplaying_puzzle_move = true; setTimeout(() => { this.autoplaying_puzzle_move = false; puzzle_place(next.x, next.y); this.emit("update"); }, this.puzzle_autoplace_delay); } } else { /* default to wrong answer, but only if there are no nodes prior to us that were marked * as correct */ let c = this.engine.cur_move; let parent_was_correct = false; while (c) { if (c.correct_answer) { parent_was_correct = true; break; } c = c.parent; } if (!parent_was_correct) { /* default to wrong answer - we say ! here because we will have already emitted * puzzle-wrong-answer if wrong_answer was true above. */ if (!this.engine.cur_move.wrong_answer) { this.emit("puzzle-wrong-answer"); } //break; } } }; puzzle_place(x, y); } } } if (puzzle_mode === "setup") { if (this.engine.board[y][x] === color) { this.engine.initialStatePlace(x, y, 0); } else { this.engine.initialStatePlace(x, y, color); } } this.emit("update"); if (removed_count > 0) { this.emit("audio-capture-stones", { count: removed_count, already_captured: 0, }); this.debouncedEmitCapturedStones(removed_stones); } } else if (this.engine.phase === "play" || (this.engine.phase === "finished" && this.mode === "analyze")) { if (this.move_selected) { if (this.mode === "play") { this.engine.cur_move.removeIfNoChildren(); } /* If same stone is clicked again, simply remove it */ let same_stone_clicked = false; if (this.move_selected.x === x && this.move_selected.y === y) { delete this.move_selected; same_stone_clicked = true; } this.engine.jumpTo(this.engine.last_official_move); /* If same stone is clicked again, simply remove it */ if (same_stone_clicked) { this.updatePlayerToMoveTitle(); if (!double_tap) { this.emit("update"); return; } } } this.move_selected = { x: x, y: y }; /* Place our stone */ try { if (!(this.mode === "analyze" && this.analyze_tool === "stone" && this.analyze_subtool !== "alternate")) { removed_count = this.engine.place(x, y, true, true, undefined, undefined, undefined, removed_stones); if (this.mode === "analyze") { if (this.engine.handicapMovesLeft() > 0) { this.engine.place(-1, -1); } } } else { if (!this.edit_color) { throw new Error(`Edit place called with invalid edit_color value`); } let edit_color = this.engine.playerByColor(this.edit_color); if (event.shiftKey && edit_color === 1) { /* if we're going to place a black on an empty square but we're holding down shift, place white */ edit_color = 2; } else if (event.shiftKey && edit_color === 2) { /* if we're going to place a black on an empty square but we're holding down shift, place white */ edit_color = 1; } if (this.engine.board[y][x] === edit_color) { this.engine.editPlace(x, y, 0); } else { this.engine.editPlace(x, y, edit_color); } } if (this.mode === "analyze" && this.analyze_tool === "stone") { let c = this.engine.cur_move; while (c && !c.trunk) { let mark = c.getMoveNumberDifferenceFromTrunk(); if (c.edited) { mark = "triangle"; } if (c.x >= 0 && c.y >= 0 && !this.engine.board[c.y][c.x]) { this.clearTransientMark(c.x, c.y, mark); } else { this.setTransientMark(c.x, c.y, mark, true); } c = c.parent