3 // Copyright 2020-2021 Ian Jackson and contributors to Otter
4 // SPDX-License-Identifier: AGPL-3.0-or-later
5 // There is NO WARRANTY.
13 // <use id="use{}", href="#piece{}" x= y= >
14 // .piece piece id (static)
15 // container to allow quick movement and hang stuff off
25 // .dragraise dragged more than this ? raise to top!
26 // .special enum RenderSpecial
28 // currently-displayed version of the piece
29 // to allow addition/removal of selected indication
30 // contains 1 or 3 subelements:
31 // one is straight from server and not modified
32 // one is possible <use href="#select{}" >
33 // one is possible <use href="#halo{}" >
36 // generated by server, referenced by JS in pelem for selection
39 // generated by server, reserved for Piece trait impl
41 type PieceId = string;
42 type PlayerId = string;
43 type Pos = [number, number];
44 type Rect = [Pos, Pos];
45 type ClientSeq = number;
46 type Generation = number;
47 type UoKind = 'Client' | "Global"| "Piece" | "ClientExtra" | "GlobalExtra";
48 type WhatResponseToClientOp = "Predictable" | "Unpredictable" | "UpdateSvg";
49 type HeldUsRaising = "NotYet" | "Lowered" | "Raised"
50 type Timestamp = number; // unix time_t, will break in 285My
51 type Layout = 'Portrait' | 'Landscape';
52 type PieceMoveable = "No" | "IfWresting" | "Yes";
53 type CompassAngle = number;
56 type UoDescription = {
58 wrc: WhatResponseToClientOp,
64 type UoRecord = UoDescription & {
65 targets: PieceId[] | null,
70 // On load, starts from SessionPieceLoadJson (Rust-only)
71 // On update, updated field-by-field from PreparedPieceState (Rust&JS)
73 held : PlayerId | null,
74 cseq_main : number | null,
75 cseq_loose: number | null,
76 cseq_updatesvg : number | null,
81 moveable: PieceMoveable,
85 uos : UoDescription[],
86 uelem : SVGGraphicsElement,
87 delem : SVGGraphicsElement,
88 pelem : SVGGraphicsElement,
89 queued_moves : number,
90 last_seen_moved : DOMHighResTimeStamp | null, // non-0 means halo'd
91 held_us_inoccult: boolean,
92 held_us_raising: HeldUsRaising,
95 special: SpecialRendering | null,
98 type SpecialRendering = {
100 stop: SpecialRenderingCallback,
103 let wasm : InitOutput;
105 var pieces : { [piece: string]: PieceInfo } = Object.create(null);
107 type MessageHandler = (op: Object) => void;
108 type PieceHandler = (piece: PieceId, p: PieceInfo, info: Object) => void;
109 type PieceErrorHandler = (piece: PieceId, p: PieceInfo, m: PieceOpError)
111 type SpecialRenderingCallback =
112 (piece: PieceId, p: PieceInfo, s: SpecialRendering) => void;
113 interface DispatchTable<H> { [key: string]: H };
115 var otter_debug: boolean;
118 var movehist_len_i: number;
119 var movehist_len_max: number;
120 var movehist_lens: number[];
122 // todo turn all var into let
123 // todo any exceptions should have otter in them or something
124 var globalinfo_elem : HTMLElement;
126 var held_surround_colour: string;
127 var general_timeout : number = 10000;
128 var messages : DispatchTable<MessageHandler> = Object();
129 var special_renderings : DispatchTable<SpecialRenderingCallback> = Object();
130 var pieceops : DispatchTable<PieceHandler> = Object();
131 var update_error_handlers : DispatchTable<MessageHandler> = Object();
132 var piece_error_handlers : DispatchTable<PieceErrorHandler> = Object();
133 var our_dnd_type = "text/puvnex-game-server-dummy";
134 var api_queue : [string, Object][] = [];
135 var api_posting = false;
137 var gen : Generation = 0;
138 var cseq : ClientSeq = 0;
140 var uo_map : { [k: string]: UoRecord | null } = Object.create(null);
141 var keyops_local : { [opname: string]: (uo: UoRecord) => void } = Object();
142 var last_log_ts: wasm_bindgen.TimestampAbbreviator;
143 var last_zoom_factor : number = 1.0;
144 var firefox_bug_zoom_factor_compensation : number = 1.0;
145 var test_update_hook : () => void;
148 var space : SVGGraphicsElement;
149 var pieces_marker : SVGGraphicsElement;
150 var defs_marker : SVGGraphicsElement;
151 var movehist_start: SVGGraphicsElement;
152 var movehist_end: SVGGraphicsElement;
153 var rectsel_path: SVGGraphicsElement;
154 var log_elem : HTMLElement;
155 var logscroll_elem : HTMLElement;
156 var status_node : HTMLElement;
157 var uos_node : HTMLElement;
158 var zoom_val : HTMLInputElement;
159 var zoom_btn : HTMLInputElement;
160 var links_elem : HTMLElement;
161 var was_wresting: boolean;
162 var wresting: boolean;
163 var occregions: wasm_bindgen.RegionList;
164 let special_count: number | null;
166 var movehist_gen: number = 0;
167 const MOVEHIST_ENDS = 2.5;
168 const SPECIAL_MULTI_DELTA_EACH = 3;
169 const SPECIAL_MULTI_DELTA_MAX = 18;
171 type PaneName = string;
172 const pane_keys : { [key: string]: PaneName } = {
178 const uo_kind_prec : { [kind: string]: number } = {
190 var players : { [player: string]: PlayerInfo };
192 type MovementRecord = { // for yellow halo, unrelasted to movehist
195 this_motion: DOMHighResTimeStamp,
197 var movements : MovementRecord[] = [];
199 function xhr_post_then(url : string, data: string,
200 good : (xhr: XMLHttpRequest) => void) {
201 var xhr : XMLHttpRequest = new XMLHttpRequest();
202 xhr.onreadystatechange = function(){
203 if (xhr.readyState != XMLHttpRequest.DONE) { return; }
204 if (xhr.status != 200) { xhr_report_error(xhr); }
207 xhr.timeout = general_timeout;
208 xhr.open('POST',url);
209 xhr.setRequestHeader('Content-Type','application/json');
213 function xhr_report_error(xhr: XMLHttpRequest) {
215 statusText : xhr.statusText,
216 responseText : xhr.responseText,
220 function json_report_error(error_for_json: Object) {
221 let error_message = JSON.stringify(error_for_json);
222 string_report_error(error_message);
225 function string_report_error(error_message: String) {
226 string_report_error_raw('Error (reloading may help?): ' + error_message)
228 function string_report_error_raw(error_message: String) {
229 let errornode = document.getElementById('error')!;
230 errornode.textContent += '\n' + error_message;
231 console.error("ERROR reported via log", error_message);
232 // todo want to fix this for at least basic game reconfigs, auto-reload?
235 function api_immediate(meth: string, data: Object) {
236 api_queue.push([meth, data]);
239 function api_delay(meth: string, data: Object) {
240 if (api_queue.length==0) window.setTimeout(api_check, 10);
241 api_queue.push([meth, data]);
243 function api_check() {
244 if (api_posting) { return; }
245 if (!api_queue.length) { test_update_hook(); return; }
247 var [meth, data] = api_queue.shift()!;
248 if (meth != 'm') break;
249 let piece = (data as any).piece;
250 let p = pieces[piece];
251 if (p == null) break;
253 if (p.queued_moves == 0) break;
254 } while (api_queue.length);
256 xhr_post_then('/_/api/'+meth, JSON.stringify(data), api_posted);
258 function api_posted() {
263 function api_piece_x(f: (meth: string, payload: Object) => void,
266 piece: PieceId, p: PieceInfo,
285 function api_piece(meth: string,
286 piece: PieceId, p: PieceInfo,
288 api_piece_x(api_immediate, false,meth, piece, p, op);
291 function svg_element(id: string): SVGGraphicsElement | null {
292 let elem = document.getElementById(id);
293 return elem as unknown as (SVGGraphicsElement | null);
295 function piece_element(base: string, piece: PieceId): SVGGraphicsElement | null
297 return svg_element(base+piece);
300 function piece_moveable(p: PieceInfo) {
301 return p.moveable == 'Yes' || p.moveable == 'IfWresting' && wresting;
303 function treat_as_pinned(p: { pinned: boolean }): boolean {
304 return p.pinned && !wresting;
306 function pinned_message_for_log(p: PieceInfo): string {
307 return 'That piece ('+p.desc+') is pinned to the table.';
310 // ----- key handling -----
312 function recompute_keybindings() {
313 uo_map = Object.create(null);
314 let all_targets = [];
316 // Types here are a little messy
317 function prep_add_uo(uo: UoDescription)
318 : null | { targets: PieceId[] | null }
320 let currently = uo_map[uo.def_key];
321 if (currently === null) return null;
322 if (currently !== undefined) {
323 if (currently.opname != uo.opname) {
324 uo_map[uo.def_key] = null;
332 uo_map[uo.def_key] = currently;
334 currently.desc = currently.desc < uo.desc ? currently.desc : uo.desc;
335 return currently as unknown as any;
338 for (let piece of Object.keys(pieces)) {
339 let p = pieces[piece];
340 if (p.held != us) continue;
341 all_targets.push(piece);
342 for (var uo of p.uos) {
343 let currently = prep_add_uo(uo);
345 currently.targets!.push(piece);
349 all_targets.sort(pieceid_z_cmp);
350 let add_uo = function(targets: PieceId[] | null, uo: UoDescription) {
351 let currently = prep_add_uo(uo);
353 currently.targets = targets;
356 if (all_targets.length) {
357 let got_rotateable = false;
358 for (let t of all_targets) {
359 if (pieces[t]!.rotateable)
360 got_rotateable = true;
362 if (got_rotateable) {
363 add_uo(all_targets, {
370 add_uo(all_targets, {
375 desc: "rotate right",
378 add_uo(all_targets, {
383 desc: "send to bottom (below other pieces)",
385 add_uo(all_targets, {
390 desc: "raise to top",
393 if (all_targets.length) {
395 for (let t of all_targets) {
396 got |= 1 << Number(pieces[t]!.pinned);
399 add_uo(all_targets, {
403 desc: 'Pin to table',
406 } else if (got == 2) {
407 add_uo(all_targets, {
411 desc: 'Unpin from table',
417 def_key: wresting ? 'W SPC' /* won't match, handle ad-hoc */ : 'W',
420 desc: wresting ? 'Exit wresting mode' : 'Enter wresting mode',
423 if (special_count != null) {
425 if (special_count == 0) {
426 desc = 'select bottommost';
428 desc = `select ${special_count}`;
430 desc = `cancel <strong style="color:purple">${desc}</strong>`;
432 def_key: 'SPC', // won't match key event; we handle this ad-hoc
434 opname: 'cancel-special',
442 opname: 'motion-hint-history',
443 desc: 'Recent history display',
446 var uo_keys = Object.keys(uo_map);
447 uo_keys.sort(function (ak,bk) {
450 if (a==null || b==null) return (
454 return uo_kind_prec[a.kind] - uo_kind_prec[b.kind]
455 || ak.localeCompare(bk);
458 for (let celem = uos_node.firstElementChild;
461 var nextelem = celem.nextElementSibling;
462 let cid = celem.getAttribute("id");
463 if (cid == "uos-mid") mid_elem = celem;
464 else if (celem.getAttribute("class") == 'uos-mid') { }
467 for (var kk of uo_keys) {
470 let prec = uo_kind_prec[uo.kind];
471 let ent = document.createElement('div');
472 ent.innerHTML = '<b>' + kk + '</b> ' + uo.desc;
474 ent.setAttribute('class','uokey-l');
475 uos_node.insertBefore(ent, mid_elem);
477 ent.setAttribute('class','uokey-r');
478 uos_node.appendChild(ent);
483 function some_keydown(e: KeyboardEvent) {
484 // https://developer.mozilla.org/en-US/docs/Web/API/Document/keydown_event
485 // says to do this, something to do with CJK composition.
486 // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent
487 // says that keyCode is deprecated
488 // my tsc says this isComposing thing doesn't exist. wat.
489 if ((e as any).isComposing /* || e.keyCode === 229 */) return;
490 if (e.ctrlKey || e.altKey || e.metaKey) return;
492 // someone else is dealing with it ?
493 let tag = (e.target as HTMLElement).tagName;
494 if (tag == 'INPUT') return;
497 let y = function() { e.preventDefault(); e.stopPropagation(); }
499 let pane = pane_keys[e.key];
502 return pane_switch(pane);
505 let special_count_key = parseInt(e.key);
506 if (isFinite(special_count_key)) {
508 if (special_count == null) special_count = 0;
510 special_count += special_count_key;
511 special_count %= 100000;
512 mousecursor_etc_reupdate();
515 if (e.key == ' ' || (e.key == 'W' && wresting)) {
517 special_count = null;
519 mousecursor_etc_reupdate();
522 if (e.key == 'Backspace') {
523 if (special_count == null) {
525 } else if (special_count >= 10) {
526 special_count = Math.round(special_count / 10 - .45);
528 special_count = null;
530 mousecursor_etc_reupdate();
534 let uo = uo_map[e.key];
535 if (uo === undefined || uo === null) return;
538 console.log('KEY UO', e, uo);
539 if (uo.kind == 'Client' || uo.kind == 'ClientExtra') {
540 let f = keyops_local[uo.opname];
544 if (!(uo.kind == 'Global' || uo.kind == 'GlobalExtra' || uo.kind == 'Piece'))
545 throw 'bad kind '+uo.kind;
547 for (var piece of uo.targets!) {
548 let p = pieces[piece]!;
549 api_piece('k', piece, p, { opname: uo.opname, wrc: uo.wrc });
550 if (uo.wrc == 'UpdateSvg') {
551 // No UpdateSvg is loose, so no need to check p.cseq_loose
552 p.cseq_updatesvg = p.cseq_main;
553 redisplay_ancillaries(piece,p);
558 function pane_switch(newpane: PaneName) {
561 new_e = document.getElementById('pane_' + newpane)!;
562 let style = new_e.getAttribute('style');
563 if (style || newpane == 'help') break;
566 for (let old_e = new_e.parentElement!.firstElementChild;
568 old_e = old_e.nextElementSibling) {
569 old_e.setAttribute('style','display: none;');
571 new_e.removeAttribute('style');
574 function mousecursor_etc_reupdate() {
575 let style_elem = document.getElementById("space-cursor-style")!;
579 let path = 'stroke-linecap="square" d="M -10 -10 10 10 M 10 -10 -10 10"';
581 document.getElementById('wresting-warning')!.innerHTML = !wresting ? "" :
582 " <strong>(wresting mode!)</strong>";
584 if (wresting != was_wresting) {
586 was_wresting = wresting;
591 if (special_count == null) {
593 } else if (special_count == 0) {
596 text = "W " + special_count;
600 `<svg xmlns="http://www.w3.org/2000/svg"
601 viewBox="-60 -15 120 60" width="120" height="60">
602 <g transform="translate(0 0)">
603 <path stroke-width="8" stroke="black" ${path}/>
604 <path stroke-width="4" stroke="#cc0" ${path}/>
605 <text x="0" y="40" fill="black" stroke="#cc0" stroke-width="1.7" text-align="center" text-anchor="middle"
606 font-family="sans-serif" font-size="30">${text}</text>
608 } else if (special_count == null) {
610 if (special_count != 0) {
611 let text_len = special_count.toString().length;
612 let text_x = text_len <= 3 ? 0 : -15;
613 let text_size = text_len <= 3 ? 50 : 45 * (4/text_len);
616 `<svg xmlns="http://www.w3.org/2000/svg"
617 viewBox="-15 0 120 65" width="120" height="65">
618 <g transform="translate(0 50)">
619 <path stroke-width="8" stroke="#fcf" ${path}/>
620 <path stroke-width="4" stroke="purple" ${path}/>
621 <text x="${text_x}" y="0" fill="purple" stroke="#fcf" stroke-width="2"
622 font-family="sans-serif" font-size="${text_size}">${special_count}</text>
625 let path = 'stroke-linecap="square" d="M -10 -10 0 0 10 -10 M 0 0 0 -20"';
628 `<svg xmlns="http://www.w3.org/2000/svg"
629 viewBox="-15 -25 30 30" width="30" height="30">
630 <g transform="translate(0 0)">
631 <path stroke-width="8" stroke="#fcf" ${path}/>
632 <path stroke-width="4" stroke="purple" ${path}/>
636 // Empirically, setting this to '' and then back to the SVG data
637 // seems to cause Firefox to update it more promptly.
638 style_elem.innerHTML = '';
639 if (svg !== undefined) {
640 let svg_data = btoa(svg);
643 cursor: url(data:image/svg+xml;base64,${svg_data}) ${xy}, auto;
645 style_elem.innerHTML = style_text;
647 recompute_keybindings();
650 keyops_local['left' ] = function (uo: UoRecord) { rotate_targets(uo, +1); }
651 keyops_local['right'] = function (uo: UoRecord) { rotate_targets(uo, -1); }
653 function rotate_targets(uo: UoRecord, dangle: number): boolean {
654 for (let piece of uo.targets!) {
655 let p = pieces[piece]!;
656 if (!p.rotateable) continue;
657 p.angle += dangle + 8;
659 let transform = wasm_bindgen.angle_transform(p.angle);
660 p.pelem.setAttributeNS(null,'transform',transform);
661 api_piece('rotate', piece,p, p.angle);
663 recompute_keybindings();
669 type LowerTodoItem = {
675 type LowerTodoList = { [piece: string]: LowerTodoItem };
677 keyops_local['lower'] = function (uo: UoRecord) { lower_targets(uo); }
679 function lower_heavy(p: PieceInfo): boolean {
680 return wresting || p.pinned || p.moveable == "No";
683 function lower_targets(uo: UoRecord): boolean {
684 let targets_todo : LowerTodoList = Object.create(null);
686 for (let piece of uo.targets!) {
687 let p = pieces[piece]!;
688 let heavy = lower_heavy(p);
689 targets_todo[piece] = { p, piece, heavy, };
691 let problem = lower_pieces(targets_todo);
692 if (problem !== null) {
693 add_log_message('Cannot lower: ' + problem);
699 function lower_pieces(targets_todo: LowerTodoList):
702 // This is a bit subtle. We don't want to lower below heavy pieces
703 // (unless we are heavy too, or the user is wresting). But maybe
704 // the heavy pieces aren't already at the bottom. For now we will
705 // declare that all heavy pieces "should" be below all light
706 // ones. Not as an invariant, but as a thing we will do here to try
707 // to make a sensible result. We implement this as follows: if we
708 // find heavy pieces above light pieces, we move those heavy
709 // pieces to the bottom too, just below us, preserving their
712 // Disregarding heavy targets:
714 // Z <some stuff not including any light targets>
716 // topmost light target *
718 // B light non-target
719 // B | light target *
720 // B | heavy non-target, mis-stacked *
723 // bottommost light non-target
724 // if that is below topmost light target
725 // <- tomove_light: insert targets from * here Q ->
726 // <- tomove_misstacked: insert non-targets from * here Q ->
727 // <- heavy non-targets with clashing Z Coords X ->
729 // A heavy non-targets (nomove_heavy)
730 // <- tomove_heavy: insert all heavy targets here P ->
732 // When wresting, treat all targets as heavy.
738 // bottom of the stack order first
739 let tomove_light : Entry[] = [];
740 let tomove_misstacked : Entry[] = [];
741 let nomove_heavy : Entry[] = [];
742 let tomove_heavy : Entry[] = [];
745 let q_z_top : ZCoord | null = null; // null some some
746 let n_targets_todo_light = 0; // 0
748 let any_targets = false;
749 for (const piece of Object.keys(targets_todo)) {
751 let p = targets_todo[piece];
752 if (!p.heavy) n_targets_todo_light++;
754 if (!any_targets) return 'Nothing to lower!';
756 let walk = pieces_marker;
757 for (;;) { // starting at the bottom of the stack order
758 if (Object.keys(targets_todo).length == 0 &&
760 // no targets left, state Z, we can stop now
761 console.log('LOWER STATE Z FINISHED');
765 let new_walk = walk.nextElementSibling;
766 if (new_walk == null) {
767 console.log('LOWER WALK NO SIBLING!');
770 walk = new_walk as SVGGraphicsElement;
771 let piece = walk.dataset.piece;
773 console.log('LOWER WALK REACHED TOP');
777 let p = pieces[piece]!;
778 let todo = targets_todo[piece];
781 if (q_z_top === null && !todo.heavy) {
785 console.log('LOWER WALK', piece, 'TODO', todo.heavy ? "H" : "_", xst);
786 delete targets_todo[piece];
787 if (!todo.heavy) n_targets_todo_light--;
788 (todo.heavy ? tomove_heavy : tomove_light).push(todo);
792 let p_heavy = lower_heavy(p);
793 if (q_z_top === null) { // state A
795 console.log('LOWER WALK', piece, 'STATE A -> Z');
798 console.log('LOWER WALK', piece, 'STATE A');
799 nomove_heavy.push({ p, piece });
806 console.log('LOWER WALK', piece, 'STATE B MIS-STACKED');
807 tomove_misstacked.push({ p, piece });
809 console.log('LOWER WALK', piece, 'STATE B');
813 if (q_z_top === null) {
814 // Somehow we didn't find the top of Q, so we didn't meet any
815 // targets. (In the walk loop, we always set q_z_top if todo.)
817 tomove_misstacked.length ? tomove_misstacked[0].p.z :
818 tomove_light .length ? tomove_light [0].p.z :
819 tomove_heavy [0].p.z;
822 while (nomove_heavy.length &&
823 (tomove_light.length || tomove_misstacked.length) &&
824 nomove_heavy[nomove_heavy.length-1].p.z == q_z_top) {
825 // Yowzer. We have to reset the Z coordinates on these heavy
826 // pieces, whose Z coordinate is the same as the stuff we are not
827 // touching, because otherwise there is no gap.
829 // Treating them as misstacked instead is sufficient, provided
830 // we put them at the front (bottom end) of the misstacked list.
832 // This is X in the chart.
834 let restack = nomove_heavy.pop()!;
835 console.log('LOWER CLASHING Z - RESTACKING', restack);
836 tomove_misstacked.unshift(restack);
840 content: Entry[], // bottom to top
842 z_bot: ZCoord | null,
845 let plan : PlanEntry[] = [];
847 console.log('LOWER PARTQ X', tomove_misstacked);
848 console.log('LOWER PARTQ L', tomove_light);
849 console.log('LOWER PARTP H', tomove_heavy);
850 let partQ = tomove_misstacked.concat(tomove_light);
851 let partP = tomove_heavy;
853 if (nomove_heavy.length == 0) {
855 content: partP.concat(partQ),
863 z_bot: nomove_heavy[nomove_heavy.length-1].p.z,
866 z_top: nomove_heavy[0].p.z,
871 console.log('LOWER PLAN', plan);
873 for (const pe of plan) {
874 for (const e of pe.content) {
875 if (e.p.held != null && e.p.held != us) {
876 return "lowering would disturb a piece held by another player";
881 for (const pe of plan) {
882 let z_top = pe.z_top;
883 let z_bot = pe.z_bot;
884 if (! pe.content.length) continue;
886 let first_z = pe.content[0].p.z;
887 if (z_top >= first_z)
890 let zrange = wasm_bindgen.range(z_bot, z_top, pe.content.length);
891 console.log('LOQER PLAN PE',
892 pe, z_bot, z_top, pe.content.length, zrange.debug());
893 for (const e of pe.content) {
895 p.held_us_raising = "Lowered";
896 piece_set_zlevel(e.piece, p, (oldtop_piece) => {
897 let z = zrange.next();
899 api_piece("setz", e.piece, e.p, { z });
906 keyops_local['wrest'] = function (uo: UoRecord) {
907 wresting = !wresting;
908 mousecursor_etc_reupdate();
911 keyops_local['motion-hint-history'] = function (uo: UoRecord) {
913 movehist_len_i %= movehist_lens.length;
914 movehist_revisible();
917 keyops_local['pin' ] = function (uo) {
918 if (!lower_targets(uo)) return;
921 keyops_local['unpin'] = function (uo) {
922 pin_unpin(uo, false);
925 function pin_unpin(uo: UoRecord, newpin: boolean) {
926 for (let piece of uo.targets!) {
927 let p = pieces[piece]!;
929 api_piece('pin', piece,p, newpin);
930 redisplay_ancillaries(piece,p);
932 recompute_keybindings();
935 // ----- raising -----
937 keyops_local['raise'] = function (uo: UoRecord) { raise_targets(uo); }
939 function raise_targets(uo: UoRecord) {
941 for (let piece of uo.targets!) {
942 let p = pieces[piece]!;
943 if (p.pinned || !piece_moveable(p)) continue;
945 piece_raise(piece, p, "NotYet");
948 add_log_message('No pieces could be raised.');
952 function piece_raise(piece: PieceId, p: PieceInfo,
953 new_held_us_raising: HeldUsRaising,
954 implement: (piece: PieceId, p: PieceInfo, z: ZCoord) => void
955 = function(piece: PieceId, p: PieceInfo, z: ZCoord) {
956 api_piece("setz", piece,p, { z: z });
959 p.held_us_raising = new_held_us_raising;
960 piece_set_zlevel(piece,p, (oldtop_piece) => {
961 let oldtop_p = pieces[oldtop_piece]!;
962 let z = wasm_bindgen.increment(oldtop_p.z);
964 implement(piece,p,z);
968 // ----- clicking/dragging pieces -----
976 enum DRAGGING { // bitmask
984 var drag_pieces : DragInfo[] = [];
985 var dragging = DRAGGING.NO;
986 var dcx : number | null;
987 var dcy : number | null;
989 const DRAGTHRESH = 5;
991 let rectsel_start: Pos | null;
992 let rectsel_shifted: boolean | null;
993 let rectsel_started_on_whynot: string | null;
994 let rectsel_started_on_grab: PieceId | null;
995 const RECTSELTHRESH = 5;
997 function piece_xy(p: PieceInfo): Pos {
998 return [ parseFloat(p.uelem.getAttributeNS(null,"x")!),
999 parseFloat(p.uelem.getAttributeNS(null,"y")!) ];
1002 function drag_start_prepare(new_dragging: DRAGGING) {
1003 dragging = new_dragging;
1005 let spos_map = Object.create(null);
1006 for (let piece of Object.keys(pieces)) {
1007 let p = pieces[piece]!;
1008 if (p.held != us) continue;
1009 let spos = piece_xy(p);
1010 let sposk = `${spos[0]} ${spos[1]}`;
1011 if (spos_map[sposk] === undefined) spos_map[sposk] = [spos, []];
1012 spos_map[sposk][1].push([spos, piece,p]);
1015 for (let sposk of Object.keys(spos_map)) {
1016 let [[dox, doy], ents] = spos_map[sposk];
1017 for (let i=0; i<ents.length; i++) {
1018 let [p, piece] = ents[i];
1019 let delta = (-(ents.length-1)/2 + i) * SPECIAL_MULTI_DELTA_EACH;
1020 p.drag_delta = Math.min(Math.max(delta, -SPECIAL_MULTI_DELTA_MAX),
1021 +SPECIAL_MULTI_DELTA_MAX);
1024 dox: dox + p.drag_delta,
1031 function some_mousedown(e : MouseEvent) {
1032 console.log('mousedown', e, e.clientX, e.clientY, e.target);
1034 if (e.button != 0) { return }
1035 if (e.altKey) { return }
1036 if (e.metaKey) { return }
1041 e.stopPropagation();
1042 drag_mousedown(e, e.shiftKey);
1046 type MouseFindClicked = null | MouseFoundClicked;
1047 type MouseFoundClicked = {
1049 held: PlayerId | null,
1054 type PieceSet = { [piece: string]: true };
1056 function grab_clicked(clicked: PieceId[], loose: boolean,
1057 multigrab: number | undefined) {
1058 for (let piece of clicked) {
1059 let p = pieces[piece]!;
1060 set_grab_us(piece,p);
1061 if (multigrab === undefined) {
1062 api_piece_x(api_immediate, loose,
1063 wresting ? 'wrest' : 'grab', piece,p, { });
1065 piece_raise(piece,p, 'Raised', function(piece,p,z) {
1066 api_piece_x(api_immediate, loose, 'multigrab',
1067 piece,p, { n: multigrab, z: z });
1072 function ungrab_clicked(clicked: PieceId[]) {
1073 let todo: [PieceId, PieceInfo][] = [];
1074 for (let tpiece of clicked) {
1075 let tp = pieces[tpiece]!;
1076 todo.push([tpiece, tp]);
1081 function mouse_clicked_one(piece: PieceId, p: PieceInfo): MouseFindClicked {
1083 let pinned = p.pinned;
1084 return { clicked: [piece], held, pinned };
1087 function mouse_find_predicate(
1088 wanted: number | null,
1089 allow_for_deselect: boolean,
1090 note_already: PieceSet | null,
1091 predicate: (p: PieceInfo) => boolean
1092 ): MouseFindClicked {
1093 let clicked: PieceId[];
1094 let held: string | null;
1096 let already_count = 0;
1099 let uelem = defs_marker;
1100 while (wanted == null || (clicked.length + already_count) < wanted) {
1101 let i = clicked.length;
1102 uelem = uelem.previousElementSibling as any;
1103 if (uelem == pieces_marker) {
1104 if (wanted != null) {
1105 add_log_message(`Not enough pieces! Stopped after ${i}.`);
1110 let piece = uelem.dataset.piece!;
1112 function is_already() {
1113 if (note_already != null) {
1115 note_already[piece] = true;
1119 let p = pieces[piece];
1120 if (treat_as_pinned(p)) continue;
1121 if (p.held && p.held != us && !wresting) continue;
1122 if (i > 0 && !piece_moveable(p))
1124 if (!predicate(p)) {
1127 if (p.pinned) pinned = true;
1131 if (held == us && !allow_for_deselect) held = null;
1134 // user is going to be deselecting
1136 // skip ones we don't have
1140 } else { // user is going to be selecting
1143 continue; // skip ones we have already
1144 } else if (p.held == null) {
1146 held = p.held; // wrestish
1149 clicked.push(piece);
1151 if (clicked.length == 0) return null;
1152 else return { clicked, held: held!, pinned: pinned! };
1155 function mouse_find_lowest(e: MouseEvent) {
1156 let clickpos = mouseevent_pos(e);
1157 let uelem = pieces_marker;
1159 uelem = uelem.nextElementSibling as any;
1160 if (uelem == defs_marker) break;
1161 let piece = uelem.dataset.piece!;
1162 let p = pieces[piece]!;
1163 if (p_bbox_contains(p, clickpos)) {
1164 return mouse_clicked_one(piece, p);
1170 function mouse_find_clicked(e: MouseEvent,
1171 target: SVGGraphicsElement, piece: PieceId,
1172 count_allow_for_deselect: boolean,
1173 note_already: PieceSet | null,
1176 let p = pieces[piece]!;
1177 if (special_count == null) {
1178 return mouse_clicked_one(piece, p);
1179 } else if (special_count == 0) {
1180 return mouse_find_lowest(e);
1181 } else { // special_count > 0
1182 if (p.multigrab && !wresting) {
1183 let clicked = mouse_clicked_one(piece, p);
1184 if (clicked) clicked.multigrab = special_count;
1187 if (special_count > 99) {
1189 `Refusing to try to select ${special_count} pieces (max is 99)`);
1192 let clickpos = mouseevent_pos(e);
1193 return mouse_find_predicate(
1194 special_count, count_allow_for_deselect, note_already,
1195 function(p) { return p_bbox_contains(p, clickpos); }
1201 function drag_mousedown(e : MouseEvent, shifted: boolean) {
1202 let target = e.target as SVGGraphicsElement; // we check this just now!
1203 let piece: PieceId | undefined = target.dataset.piece;
1205 rectsel_started_on_whynot = null;
1206 rectsel_started_on_grab = null;
1209 let p = pieces[piece]!;
1211 if (treat_as_pinned(p!)) {
1212 rectsel_started_on_whynot = pinned_message_for_log(p!);
1214 console.log('mousedown pinned');
1217 if (special_count === null && !wresting && !piece_moveable(p)) {
1218 rectsel_started_on_grab = piece!;
1220 console.log('mousedown unmoveable');
1225 console.log('mousedown rectsel');
1226 rectsel_start = mouseevent_pos(e);
1227 rectsel_shifted = shifted;
1228 window.addEventListener('mousemove', rectsel_mousemove, true);
1229 window.addEventListener('mouseup', rectsel_mouseup, true);
1233 let note_already = shifted ? null : Object.create(null);
1235 let c = mouse_find_clicked(e, target, piece, false, note_already);
1236 if (c == null) return;
1238 special_count = null;
1239 mousecursor_etc_reupdate();
1242 mouseclick_core(c, shifted, note_already);
1246 window.addEventListener('mousemove', drag_mousemove, true);
1247 window.addEventListener('mouseup', drag_mouseup, true);
1250 // Mostly, run on mousedown.
1251 // Sometimes run on mouseup, if we decided that the user might be
1252 // intending a drag instead.
1253 function mouseclick_core(c: MouseFoundClicked, shifted: boolean,
1254 note_already: PieceSet | null) {
1256 let clicked = c.clicked;
1257 let multigrab = c.multigrab;
1260 if (held == us && multigrab == null) {
1262 ungrab_clicked(clicked);
1265 drag_start_prepare(DRAGGING.MAYBE_UNGRAB);
1266 } else if (held == null || wresting) {
1268 ungrab_all_except(note_already);
1270 if (treat_as_pinned(c)) {
1271 add_log_message(pinned_message_for_log(pieces[c.clicked[0]!]!));
1274 grab_clicked(clicked, !wresting, multigrab);
1275 drag_start_prepare(DRAGGING.MAYBE_GRAB);
1277 add_log_message('That piece is held by another player.');
1283 function mouseevent_pos(e: MouseEvent): Pos {
1284 let ctm = space.getScreenCTM()!;
1285 let px = (e.clientX - ctm.e)/(ctm.a * firefox_bug_zoom_factor_compensation);
1286 let py = (e.clientY - ctm.f)/(ctm.d * firefox_bug_zoom_factor_compensation);
1287 let pos: Pos = [px, py];
1288 console.log('mouseevent_pos', pos);
1292 function p_bbox_contains(p: PieceInfo, test: Pos) {
1293 let ctr = piece_xy(p);
1294 for (let i of [0,1]) {
1295 let offset = test[i] - ctr[i];
1296 if (offset < p.bbox[0][i] || offset > p.bbox[1][i])
1302 function do_ungrab_n(todo: [PieceId, PieceInfo][]) {
1303 function sort_with(a: [PieceId, PieceInfo],
1304 b: [PieceId, PieceInfo]): number {
1305 return piece_z_cmp(a[1], b[1]);
1307 todo.sort(sort_with);
1308 for (let [tpiece, tp] of todo) {
1309 do_ungrab_1(tpiece, tp);
1312 function ungrab_all_except(dont: PieceSet | null) {
1313 let todo: [PieceId, PieceInfo][] = [];
1314 for (let tpiece of Object.keys(pieces)) {
1315 if (dont && dont[tpiece]) continue;
1316 let tp = pieces[tpiece]!;
1317 if (tp.held == us) {
1318 todo.push([tpiece, tp]);
1323 function ungrab_all() {
1324 ungrab_all_except(null);
1327 function set_grab_us(piece: PieceId, p: PieceInfo) {
1329 p.held_us_raising = "NotYet";
1331 redisplay_ancillaries(piece,p);
1332 recompute_keybindings();
1334 function do_ungrab_1(piece: PieceId, p: PieceInfo) {
1335 let autoraise = p.held_us_raising == "Raised";
1337 p.held_us_raising = "NotYet";
1339 redisplay_ancillaries(piece,p);
1340 recompute_keybindings();
1341 api_piece('ungrab', piece,p, { autoraise });
1344 function clear_halo(piece: PieceId, p: PieceInfo) {
1345 let was = p.last_seen_moved;
1346 p.last_seen_moved = null;
1347 if (was) redisplay_ancillaries(piece,p);
1350 function ancillary_node(piece: PieceId, stroke: string): SVGGraphicsElement {
1351 var nelem = document.createElementNS(svg_ns,'use');
1352 nelem.setAttributeNS(null,'href','#surround'+piece);
1353 nelem.setAttributeNS(null,'stroke',stroke);
1354 nelem.setAttributeNS(null,'fill','none');
1355 return nelem as any;
1358 function redisplay_ancillaries(piece: PieceId, p: PieceInfo) {
1359 let href = '#surround'+piece;
1360 console.log('REDISPLAY ANCILLARIES',href);
1362 for (let celem = p.pelem.firstElementChild;
1365 var nextelem = celem.nextElementSibling
1366 let thref = celem.getAttributeNS(null,"href");
1367 if (thref == href) {
1372 let halo_colour = null;
1373 if (p.cseq_updatesvg != null) {
1374 halo_colour = 'purple';
1375 } else if (p.last_seen_moved != null) {
1376 halo_colour = 'yellow';
1377 } else if (p.held != null && p.pinned) {
1378 halo_colour = '#8cf';
1380 if (halo_colour != null) {
1381 let nelem = ancillary_node(piece, halo_colour);
1382 if (p.held != null) {
1383 // value 2ps is also in src/pieces.rs SELECT_STROKE_WIDTH
1384 nelem.setAttributeNS(null,'stroke-width','2px');
1386 p.pelem.prepend(nelem);
1388 if (p.held != null) {
1391 da = players[p.held!]!.dasharray;
1393 let [px, py] = piece_xy(p);
1394 let inoccult = occregions.contains_pos(px, py);
1395 p.held_us_inoccult = inoccult;
1397 da = "0.9 0.6"; // dotted dasharray
1400 let nelem = ancillary_node(piece, held_surround_colour);
1402 nelem.setAttributeNS(null,'stroke-dasharray',da);
1404 p.pelem.appendChild(nelem);
1408 function drag_mousemove(e: MouseEvent) {
1409 var ctm = space.getScreenCTM()!;
1410 var ddx = (e.clientX - dcx!)/(ctm.a * firefox_bug_zoom_factor_compensation);
1411 var ddy = (e.clientY - dcy!)/(ctm.d * firefox_bug_zoom_factor_compensation);
1412 var ddr2 = ddx*ddx + ddy*ddy;
1413 if (!(dragging & DRAGGING.YES)) {
1414 if (ddr2 > DRAGTHRESH) {
1415 for (let dp of drag_pieces) {
1416 let tpiece = dp.piece;
1417 let tp = pieces[tpiece]!;
1418 if (tp.moveable == "Yes") {
1420 } else if (tp.moveable == "IfWresting") {
1421 if (wresting) continue;
1423 `That piece (${tp.desc}) can only be moved when Wresting.`);
1426 `That piece (${tp.desc}) cannot be moved at the moment.`);
1430 dragging |= DRAGGING.YES;
1433 //console.log('mousemove', ddx, ddy, dragging);
1434 if (dragging & DRAGGING.YES) {
1435 console.log('DRAG PIECES',drag_pieces);
1436 for (let dp of drag_pieces) {
1437 console.log('DRAG PIECES PIECE',dp);
1438 let tpiece = dp.piece;
1439 let tp = pieces[tpiece]!;
1440 var x = Math.round(dp.dox + ddx);
1441 var y = Math.round(dp.doy + ddy);
1442 let need_redisplay_ancillaries = (
1444 occregions.contains_pos(x,y) != tp.held_us_inoccult
1446 piece_set_pos_core(tp, x, y);
1448 api_piece_x(api_delay, false, 'm', tpiece,tp, [x, y] );
1449 if (need_redisplay_ancillaries) redisplay_ancillaries(tpiece, tp);
1451 if (!(dragging & DRAGGING.RAISED)) {
1453 for (let dp of drag_pieces) {
1454 let piece = dp.piece;
1455 let p = pieces[piece]!;
1456 if (p.held_us_raising == "Lowered") continue;
1457 let dragraise = +p.pelem.dataset.dragraise!;
1458 if (dragraise > 0 && ddr2 >= dragraise*dragraise) {
1459 dragging |= DRAGGING.RAISED;
1460 console.log('CHECK RAISE ', dragraise, dragraise*dragraise, ddr2);
1461 piece_raise(piece,p,"Raised");
1468 function sort_drag_pieces() {
1469 function sort_with(a: DragInfo, b: DragInfo): number {
1470 return pieceid_z_cmp(a.piece,
1473 drag_pieces.sort(sort_with);
1476 function drag_mouseup(e: MouseEvent) {
1477 console.log('mouseup', dragging);
1478 let ddr2 : number = drag_mousemove(e);
1482 function drag_end() {
1483 if (dragging == DRAGGING.MAYBE_UNGRAB ||
1484 (dragging & ~DRAGGING.RAISED) == (DRAGGING.MAYBE_GRAB | DRAGGING.YES)) {
1486 for (let dp of drag_pieces) {
1487 let piece = dp.piece;
1488 let p = pieces[piece]!;
1489 do_ungrab_1(piece,p);
1495 function drag_cancel() {
1496 window.removeEventListener('mousemove', drag_mousemove, true);
1497 window.removeEventListener('mouseup', drag_mouseup, true);
1498 dragging = DRAGGING.NO;
1502 function rectsel_nontrivial_pos2(e: MouseEvent): Pos | null {
1503 let pos2 = mouseevent_pos(e);
1505 for (let i of [0,1]) {
1506 let d = pos2[i] - rectsel_start![i];
1509 return d2 > RECTSELTHRESH*RECTSELTHRESH ? pos2 : null;
1512 function rectsel_mousemove(e: MouseEvent) {
1513 let pos2 = rectsel_nontrivial_pos2(e);
1518 let pos1 = rectsel_start!;
1519 path = `M ${ pos1 [0]} ${ pos1 [1] }
1520 ${ pos2 [0]} ${ pos1 [1] }
1521 M ${ pos1 [0]} ${ pos2 [1] }
1522 ${ pos2 [0]} ${ pos2 [1] }
1523 M ${ pos1 [0]} ${ pos1 [1] }
1524 ${ pos1 [0]} ${ pos2 [1] }
1525 M ${ pos2 [0]} ${ pos1 [1] }
1526 ${ pos2 [0]} ${ pos2 [1] }`;
1528 rectsel_path.firstElementChild!.setAttributeNS(null,'d',path);
1531 function rectsel_mouseup(e: MouseEvent) {
1532 console.log('rectsel mouseup');
1533 window.removeEventListener('mousemove', rectsel_mousemove, true);
1534 window.removeEventListener('mouseup', rectsel_mouseup, true);
1535 rectsel_path.firstElementChild!.setAttributeNS(null,'d','');
1536 let pos2 = rectsel_nontrivial_pos2(e);
1539 // clicked not on an unpinned piece, and didn't drag
1540 if (rectsel_started_on_whynot) {
1541 add_log_message(rectsel_started_on_whynot);
1543 if (rectsel_started_on_grab) {
1544 let p = pieces[rectsel_started_on_grab];
1545 mouseclick_core({ clicked: [rectsel_started_on_grab],
1547 pinned: treat_as_pinned(p),
1548 multigrab: undefined, },
1553 special_count = null;
1554 mousecursor_etc_reupdate();
1555 // we'll bail in a moment, after possibly unselecting things
1558 let note_already = Object.create(null);
1562 if (special_count != null && special_count == 0) {
1563 add_log_message(`Cannot drag-select lowest.`);
1568 for (let i of [0,1]) {
1569 tl[i] = Math.min(rectsel_start![i], pos2[i]);
1570 br[i] = Math.max(rectsel_start![i], pos2[i]);
1572 c = mouse_find_predicate(
1573 special_count, rectsel_shifted!, note_already,
1574 function(p: PieceInfo) {
1575 let pp = piece_xy(p);
1576 for (let i of [0,1]) {
1577 if (pp[i] < tl[i] || pp[i] > br[i]) return false;
1585 // clicked not on a piece, didn't end up selecting anything
1586 // either because drag region had nothing in it, or special
1587 // failed, or some such.
1588 if (!rectsel_shifted) {
1590 while (mr = movements.pop()) {
1591 mr.p.last_seen_moved = null;
1592 redisplay_ancillaries(mr.piece, mr.p);
1600 special_count = null;
1601 mousecursor_etc_reupdate();
1603 if (rectsel_shifted && c.held == us) {
1604 ungrab_clicked(c.clicked);
1607 if (!rectsel_shifted) {
1608 ungrab_all_except(note_already);
1610 grab_clicked(c.clicked, false, undefined);
1614 // ----- general -----
1616 type PlayersUpdate = { new_info_pane: string };
1618 messages.SetPlayer = <MessageHandler>function
1619 (j: { player: string, data: PlayerInfo } & PlayersUpdate) {
1620 players[j.player] = j.data;
1621 player_info_pane_set(j);
1624 messages.RemovePlayer = <MessageHandler>function
1625 (j: { player: string } & PlayersUpdate ) {
1626 delete players[j.player];
1627 player_info_pane_set(j);
1630 function player_info_pane_set(j: PlayersUpdate) {
1631 document.getElementById('player_list')!
1632 .innerHTML = j.new_info_pane;
1635 messages.UpdateBundles = <MessageHandler>function
1636 (j: { new_info_pane: string }) {
1637 document.getElementById('bundle_list')!
1638 .innerHTML = j.new_info_pane;
1641 messages.SetTableSize = <MessageHandler>function
1642 ([x, y]: [number, number]) {
1643 function set_attrs(elem: Element, l: [string,string][]) {
1645 elem.setAttributeNS(null,a[0],a[1]);
1648 let rect = document.getElementById('table_rect')!;
1649 set_attrs(space, wasm_bindgen.space_table_attrs(x, y));
1650 set_attrs(rect, wasm_bindgen.space_table_attrs(x, y));
1653 messages.SetTableColour = <MessageHandler>function
1655 let rect = document.getElementById('table_rect')!;
1656 rect.setAttributeNS(null, 'fill', c);
1659 messages.SetLinks = <MessageHandler>function
1661 if (msg.length != 0 && layout == 'Portrait') {
1664 links_elem.innerHTML = msg
1667 // ---------- movehist ----------
1669 type MoveHistEnt = {
1671 posx: [MoveHistPosx, MoveHistPosx],
1672 diff: { 'Moved': { d: number } },
1674 type MoveHistPosx = {
1676 angle: CompassAngle,
1677 facehint: FaceId | null,
1680 messages.MoveHistEnt = <MessageHandler>movehist_record;
1681 messages.MoveHistClear = <MessageHandler>function() {
1682 movehist_revisible_custmax(0);
1685 function movehist_record(ent: MoveHistEnt) {
1686 let old_pos = ent.posx[0].pos;
1687 let new_pos = ent.posx[1].pos;
1690 movehist_gen %= (movehist_len_max * 2);
1691 let meid = 'motionhint-marker-' + movehist_gen;
1693 let moved = ent.diff['Moved'];
1697 for (let end of [0,1]) {
1698 let s = (!end ? MOVEHIST_ENDS : d - MOVEHIST_ENDS) / d;
1699 ends.push([ (1-s) * old_pos[0] + s * new_pos[0],
1700 (1-s) * old_pos[1] + s * new_pos[1] ]);
1702 let g = document.createElementNS(svg_ns,'g');
1704 let pi = players[ent.held];
1705 let nick = pi ? pi.nick : '';
1706 // todo: would be nice to place text variously along arrow, rotated
1708 <marker id="${meid}" viewBox="2 0 ${sz} ${sz}"
1709 refX="${sz}" refY="${sz/2}"
1710 markerWidth="${sz + 2}" markerHeight="${sz}"
1711 stroke="yellow" fill="none"
1712 orient="auto-start-reverse" stroke-linejoin="miter">
1713 <path d="M 0 0 L ${sz} ${sz/2} L 0 ${sz}" />
1715 <line x1="${ends[0][0].toString()}"
1716 y1="${ends[0][1].toString()}"
1717 x2="${ends[1][0].toString()}"
1718 y2="${ends[1][1].toString()}"
1720 stroke-width="1" pointer-events="none"
1721 marker-end="url(#${meid})" />
1722 <text x="${((ends[0][0] + ends[1][0]) / 2).toString()}"
1723 y="${((ends[0][1] + ends[1][1]) / 2).toString()}"
1724 font-size="5" pointer-events="none"
1725 stroke-width="0.1">${nick}</text>
1728 space.insertBefore(g, movehist_end);
1729 movehist_revisible();
1733 function movehist_revisible() {
1734 movehist_revisible_custmax(movehist_len_max);
1737 function movehist_revisible_custmax(len_max: number) {
1738 let n = movehist_lens[movehist_len_i];
1740 let node = movehist_end;
1741 while (i < len_max) {
1742 i++; // i now eg 1..10
1743 node = node.previousElementSibling! as SVGGraphicsElement;
1744 if (node == movehist_start)
1746 let prop = i > n ? 0 : (n-i+1)/n;
1747 let stroke = (prop * 1.0).toString();
1748 let marker = node.firstElementChild!;
1749 marker.setAttributeNS(null,'stroke-width',stroke);
1750 let line = marker.nextElementSibling!;
1751 line.setAttributeNS(null,'stroke-width',stroke);
1752 let text = line.nextElementSibling!;
1754 text.setAttributeNS(null,'stroke','none');
1755 text.setAttributeNS(null,'fill','none');
1757 text.setAttributeNS(null,'fill','yellow');
1758 text.setAttributeNS(null,'stroke','orange');
1762 let del = node.previousElementSibling!;
1763 if (del == movehist_start)
1771 messages.Log = <MessageHandler>function
1772 (j: { when: string, logent: { html: string } }) {
1773 add_timestamped_log_message(j.when, j.logent.html);
1776 function add_log_message(msg_html: string) {
1777 add_timestamped_log_message('', msg_html);
1780 function add_timestamped_log_message(ts_html: string, msg_html: string) {
1781 var lastent = log_elem.lastElementChild;
1785 // https://stackoverflow.com/questions/487073/how-to-check-if-element-is-visible-after-scrolling/21627295#21627295
1787 // https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
1789 let le_top = lastent.getBoundingClientRect()!.top;
1790 let le_bot = lastent.getBoundingClientRect()!.bottom;
1791 let ld_bot = logscroll_elem.getBoundingClientRect()!.bottom;
1792 console.log("ADD_LOG_MESSAGE bboxes: le t b, bb",
1793 le_top, le_bot, ld_bot);
1794 return 0.5 * (le_bot + le_top) > ld_bot;
1797 console.log('ADD LOG MESSAGE ',in_scrollback, layout, msg_html);
1799 var ne : HTMLElement;
1801 function add_thing(elemname: string, cl: string, html: string) {
1802 var ie = document.createElement(elemname);
1803 ie.innerHTML = html;
1804 ie.setAttribute("class", cl);
1808 if (layout == 'Portrait') {
1809 ne = document.createElement('tr');
1810 add_thing('td', 'logmsg', msg_html);
1811 add_thing('td', 'logts', ts_html);
1812 } else if (layout == 'Landscape') {
1813 ts_html = last_log_ts.update(ts_html);
1814 ne = document.createElement('div');
1815 add_thing('span', 'logts', ts_html);
1816 ne.appendChild(document.createElement('br'));
1817 add_thing('span', 'logmsg', msg_html);
1818 ne.appendChild(document.createElement('br'));
1820 throw 'bad layout ' + layout;
1822 log_elem.appendChild(ne);
1824 if (!in_scrollback) {
1825 logscroll_elem.scrollTop = logscroll_elem.scrollHeight;
1831 function zoom_pct (): number | undefined {
1832 let str = zoom_val.value;
1833 let val = parseFloat(str);
1841 function zoom_enable() {
1842 zoom_btn.disabled = (zoom_pct() === undefined);
1845 function zoom_activate() {
1846 let pct = zoom_pct();
1847 if (pct !== undefined) {
1848 let fact = pct * 0.01;
1849 let last_ctm_a = space.getScreenCTM()!.a;
1850 (document.getElementsByTagName('body')[0] as HTMLElement)
1851 .style.transform = 'scale('+fact+','+fact+')';
1852 if (fact != last_zoom_factor) {
1853 if (last_ctm_a == space.getScreenCTM()!.a) {
1854 console.log('FIREFOX GETSCREENCTM BUG');
1855 firefox_bug_zoom_factor_compensation = fact;
1857 console.log('No firefox getscreenctm bug');
1858 firefox_bug_zoom_factor_compensation = 1.0;
1860 last_zoom_factor = fact;
1863 zoom_btn.disabled = true;
1866 // ----- test counter, startup -----
1868 type TransmitUpdateEntry_Piece = {
1872 type ErrorTransmitUpdateEntry_Piece = TransmitUpdateEntry_Piece & {
1873 cseq: ClientSeq | null,
1876 function handle_piece_update(j: TransmitUpdateEntry_Piece) {
1877 console.log('PIECE UPDATE ',j)
1878 var piece = j.piece;
1879 var m = j.op as { [k: string]: Object };
1880 var k = Object.keys(m)[0];
1881 let p = pieces[piece];
1882 pieceops[k](piece,p, m[k]);
1885 messages.Piece = <MessageHandler>handle_piece_update;
1887 type PreparedPieceState = {
1891 held: PlayerId | null,
1896 uos: UoDescription[],
1897 moveable: PieceMoveable,
1898 rotateable: boolean,
1900 occregion: string | null,
1904 pieceops.ModifyQuiet = <PieceHandler>function
1905 (piece: PieceId, p: PieceInfo, info: PreparedPieceState) {
1906 console.log('PIECE UPDATE MODIFY QUIET ',piece,info)
1907 piece_modify(piece, p, info);
1910 pieceops.Modify = <PieceHandler>function
1911 (piece: PieceId, p: PieceInfo, info: PreparedPieceState) {
1912 console.log('PIECE UPDATE MODIFY LOuD ',piece,info)
1913 piece_note_moved(piece,p);
1914 piece_modify(piece, p, info);
1917 pieceops.InsertQuiet = <PieceHandler>(insert_piece as any);
1918 pieceops.Insert = <PieceHandler>function
1919 (piece: PieceId, xp: any, info: PreparedPieceState) {
1920 let p = insert_piece(piece,xp,info);
1921 piece_note_moved(piece,p);
1924 function insert_piece(piece: PieceId, xp: any,
1925 info: PreparedPieceState): PieceInfo
1927 console.log('PIECE UPDATE INSERT ',piece,info)
1928 let delem = document.createElementNS(svg_ns,'defs');
1929 delem.setAttributeNS(null,'id','defs'+piece);
1930 defs_marker.insertAdjacentElement('afterend', delem);
1931 let pelem = piece_element('piece',piece);
1932 let uelem = document.createElementNS(svg_ns,'use');
1933 uelem.setAttributeNS(null,'id',"use"+piece);
1934 uelem.setAttributeNS(null,'href',"#piece"+piece);
1935 uelem.setAttributeNS(null,'data-piece',piece);
1939 } as any as PieceInfo; // fudge this, piece_modify_core will fix it
1942 piece_modify(piece, p, info);
1946 pieceops.Delete = <PieceHandler>function
1947 (piece: PieceId, p: PieceInfo, info: {}) {
1948 console.log('PIECE UPDATE DELETE ', piece)
1949 piece_stop_special(piece, p);
1952 delete pieces[piece];
1954 recompute_keybindings();
1956 let occregions_changed = occregion_update(piece, p, { occregion: null });
1957 if (occregions_changed) redisplay_held_ancillaries();
1960 piece_error_handlers.PosOffTable = <PieceErrorHandler>function()
1962 piece_error_handlers.Conflict = <PieceErrorHandler>function()
1965 function piece_modify_image(piece: PieceId, p: PieceInfo,
1966 info: PreparedPieceImage) {
1967 p.delem.innerHTML = info.svg;
1968 p.pelem= piece_element('piece',piece)!;
1972 piece_resolve_special(piece, p);
1975 function piece_resolve_special(piece: PieceId, p: PieceInfo) {
1977 p.pelem.dataset.special ?
1978 JSON.parse(p.pelem.dataset.special) : null;
1979 let new_special_kind = new_special ? new_special.kind : '';
1980 let old_special_kind = p .special ? p .special.kind : '';
1982 if (new_special_kind != old_special_kind) {
1983 piece_stop_special(piece, p);
1986 console.log('SPECIAL START', new_special);
1987 new_special.stop = function() { };
1988 p.special = new_special;
1989 special_renderings[new_special_kind](piece, p, new_special);
1993 function piece_stop_special(piece: PieceId, p: PieceInfo) {
1997 console.log('SPECIAL STOP', s);
1998 s.stop(piece, p, s);
2002 function piece_modify(piece: PieceId, p: PieceInfo, info: PreparedPieceState) {
2003 piece_modify_image(piece, p, info);
2004 piece_modify_core(piece, p, info);
2007 function piece_set_pos_core(p: PieceInfo, x: number, y: number) {
2008 p.uelem.setAttributeNS(null, "x", x+"");
2009 p.uelem.setAttributeNS(null, "y", y+"");
2012 function piece_modify_core(piece: PieceId, p: PieceInfo,
2013 info: PreparedPieceState) {
2014 p.uelem.setAttributeNS(null, "x", info.pos[0]+"");
2015 p.uelem.setAttributeNS(null, "y", info.pos[1]+"");
2017 p.held_us_raising = "NotYet";
2018 p.pinned = info.pinned;
2019 p.moveable = info.moveable;
2020 p.rotateable = info.rotateable;
2021 p.multigrab = info.multigrab;
2022 p.angle = info.angle;
2024 piece_set_zlevel_from(piece,p,info);
2025 let occregions_changed = occregion_update(piece, p, info);
2026 piece_checkconflict_nrda(piece,p);
2027 redisplay_ancillaries(piece,p);
2028 if (occregions_changed) redisplay_held_ancillaries();
2029 recompute_keybindings();
2030 console.log('MODIFY DONE');
2032 function occregion_update(piece: PieceId, p: PieceInfo,
2033 info: { occregion: string | null } ) {
2034 let occregions_changed = (
2035 info.occregion != null
2036 ? occregions.insert(piece, info.occregion)
2037 : occregions.remove(piece)
2039 return occregions_changed;
2041 function redisplay_held_ancillaries() {
2042 for (let piece of Object.keys(pieces)) {
2043 let p = pieces[piece];
2044 if (p.held != us) continue;
2045 redisplay_ancillaries(piece,p);
2049 type PreparedPieceImage = {
2052 uos: UoDescription[],
2056 type TransmitUpdateEntry_Image = {
2058 im: PreparedPieceImage,
2061 messages.Image = <MessageHandler>function(j: TransmitUpdateEntry_Image) {
2062 console.log('IMAGE UPDATE ',j)
2063 var piece = j.piece;
2064 let p = pieces[piece]!;
2065 piece_modify_image(piece, p, j.im);
2066 redisplay_ancillaries(piece,p);
2067 recompute_keybindings();
2068 console.log('IMAGE DONE');
2071 function piece_set_zlevel(piece: PieceId, p: PieceInfo,
2072 modify : (oldtop_piece: PieceId) => void) {
2073 // Calls modify, which should set .z and/or .gz, and/or
2074 // make any necessary API call.
2076 // Then moves uelem to the right place in the DOM. This is done
2077 // by assuming that uelem ought to go at the end, so this is
2078 // O(new depth), which is right (since the UI for inserting
2079 // an object is itself O(new depth) UI operations to prepare.
2081 let oldtop_elem = (defs_marker.previousElementSibling! as
2082 unknown as SVGGraphicsElement);
2083 let oldtop_piece = oldtop_elem.dataset.piece!;
2084 modify(oldtop_piece);
2086 let ins_before = defs_marker
2088 for (; ; ins_before = earlier_elem) {
2089 earlier_elem = (ins_before.previousElementSibling! as
2090 unknown as SVGGraphicsElement);
2091 if (earlier_elem == pieces_marker) break;
2092 if (earlier_elem == p.uelem) continue;
2093 let earlier_p = pieces[earlier_elem.dataset.piece!]!;
2094 if (!piece_z_before(p, earlier_p)) break;
2096 if (ins_before != p.uelem)
2097 space.insertBefore(p.uelem, ins_before);
2102 function check_z_order() {
2103 if (!otter_debug) return;
2104 let s = pieces_marker;
2107 s = s.nextElementSibling as SVGGraphicsElement;
2108 if (s == defs_marker) break;
2109 let piece = s.dataset.piece!;
2110 let z = pieces[piece].z;
2112 json_report_error(['Z ORDER INCONSISTENCY!', piece, z, last_z]);
2118 function piece_note_moved(piece: PieceId, p: PieceInfo) {
2119 let now = performance.now();
2121 let need_redisplay = p.last_seen_moved == null;
2122 p.last_seen_moved = now;
2123 if (need_redisplay) redisplay_ancillaries(piece,p);
2125 let cutoff = now-1000.;
2126 while (movements.length > 0 && movements[0].this_motion < cutoff) {
2127 let mr = movements.shift()!;
2128 if (mr.p.last_seen_moved != null &&
2129 mr.p.last_seen_moved < cutoff) {
2130 mr.p.last_seen_moved = null;
2131 redisplay_ancillaries(mr.piece,mr.p);
2135 movements.push({ piece: piece, p: p, this_motion: now });
2138 function piece_z_cmp(a: PieceInfo, b: PieceInfo) {
2139 if (a.z < b.z ) return -1;
2140 if (a.z > b.z ) return +1;
2141 if (a.zg < b.zg) return -1;
2142 if (a.zg > b.zg) return +1;
2146 function piece_z_before(a: PieceInfo, b: PieceInfo) {
2147 return piece_z_cmp(a,
2151 function pieceid_z_cmp(a: PieceId, b: PieceId) {
2152 return piece_z_cmp(pieces[a]!,
2156 pieceops.Move = <PieceHandler>function
2157 (piece,p, info: Pos ) {
2158 piece_checkconflict_nrda(piece,p);
2159 piece_note_moved(piece, p);
2160 piece_set_pos_core(p, info[0], info[1]);
2163 pieceops.MoveQuiet = <PieceHandler>function
2164 (piece,p, info: Pos ) {
2165 piece_checkconflict_nrda(piece,p);
2166 piece_set_pos_core(p, info[0], info[1]);
2169 pieceops.SetZLevel = <PieceHandler>function
2170 (piece,p, info: { z: ZCoord, zg: Generation }) {
2171 piece_note_moved(piece,p);
2172 piece_set_zlevel_from(piece,p,info);
2175 pieceops.SetZLevelQuiet = <PieceHandler>function
2176 (piece,p, info: { z: ZCoord, zg: Generation }) {
2177 piece_set_zlevel_from(piece,p,info);
2180 function piece_set_zlevel_from(piece: PieceId, p: PieceInfo,
2181 info: { z: ZCoord, zg: Generation }) {
2182 piece_set_zlevel(piece,p, (oldtop_piece)=>{
2188 messages.Recorded = <MessageHandler>function
2189 (j: { piece: PieceId, cseq: ClientSeq,
2190 zg: Generation|null, svg: string | null, desc: string | null } ) {
2191 let piece = j.piece;
2192 let p = pieces[piece]!;
2193 piece_recorded_cseq(p, j);
2194 if (p.cseq_updatesvg != null && j.cseq >= p.cseq_updatesvg) {
2195 p.cseq_updatesvg = null;
2196 redisplay_ancillaries(piece,p);
2198 if (j.svg != null) {
2199 p.delem.innerHTML = j.svg;
2200 p.pelem= piece_element('piece',piece)!;
2201 piece_resolve_special(piece, p);
2202 redisplay_ancillaries(piece,p);
2205 var zg_new = j.zg; // type narrowing doesn't propagate :-/
2206 piece_set_zlevel(piece,p, (oldtop_piece: PieceId)=>{
2210 if (j.desc != null) {
2215 function piece_recorded_cseq(p: PieceInfo, j: { cseq: ClientSeq }) {
2216 if (p.cseq_main != null && j.cseq >= p.cseq_main ) { p.cseq_main = null; }
2217 if (p.cseq_loose != null && j.cseq >= p.cseq_loose) { p.cseq_loose = null; }
2220 messages.RecordedUnpredictable = <MessageHandler>function
2221 (j: { piece: PieceId, cseq: ClientSeq, ns: PreparedPieceState } ) {
2222 let piece = j.piece;
2223 let p = pieces[piece]!;
2224 piece_recorded_cseq(p, j);
2225 piece_modify(piece, p, j.ns);
2228 messages.Error = <MessageHandler>function
2230 console.log('ERROR UPDATE ', m);
2231 var k = Object.keys(m)[0];
2232 update_error_handlers[k](m[k]);
2235 type PieceOpError = {
2238 state: ErrorTransmitUpdateEntry_Piece,
2241 update_error_handlers.PieceOpError = <MessageHandler>function
2243 let piece = m.state.piece;
2244 let cseq = m.state.cseq;
2245 let p = pieces[piece];
2246 console.log('ERROR UPDATE PIECE ', piece, cseq, m, m.error_msg, p);
2247 if (p == null) return; // who can say!
2248 if (m.error != 'Conflict') {
2249 // Our gen was high enough we we sent this, that it ought to have
2250 // worked. Report it as a problem, then.
2251 add_log_message('Problem manipulating piece: ' + m.error_msg);
2252 // Mark aus as having no outstanding requests, and cancel any drag.
2253 piece_checkconflict_nrda(piece, p, true);
2255 handle_piece_update(m.state);
2258 function piece_checkconflict_nrda(piece: PieceId, p: PieceInfo,
2259 already_logged: boolean = false) {
2260 // Our state machine for cseq:
2262 // When we send an update (api_piece_x) we always set cseq. If the
2263 // update is loose we also set cseq_beforeloose. Whenever we
2264 // clear cseq we clear cseq_beforeloose too.
2266 // The result is that if cseq_beforeloose is non-null precisely if
2267 // the last op we sent was loose.
2269 // We track separately the last loose, and the last non-loose,
2270 // outstanding API request. (We discard our idea of the last
2271 // loose request if we follow it with a non-loose one.)
2274 // cseq_main > cseq_loose one loose request then some non-loose
2275 // cseq_main, no cseq_loose just non-loose requests
2276 // no cseq_main, but cseq_loose just one loose request
2277 // neither no outstanding requests
2279 // If our only outstanding update is loose, we ignore a detected
2280 // conflict. We expect the server to send us a proper
2281 // (non-Conflict) error later.
2282 if (p.cseq_main != null || p.cseq_loose != null) {
2283 if (drag_pieces.some(function(dp) { return dp.piece == piece; })) {
2284 console.log('drag end due to conflict');
2288 if (p.cseq_main != null) {
2289 if (!already_logged)
2290 add_log_message('Conflict! - simultaneous update');
2293 p.cseq_loose = null;
2296 function test_swap_stack() {
2297 let old_bot = pieces_marker.nextElementSibling!;
2298 let container = old_bot.parentElement!;
2299 container.insertBefore(old_bot, defs_marker);
2300 window.setTimeout(test_swap_stack, 1000);
2303 function startup() {
2304 console.log('STARTUP');
2305 console.log(wasm_bindgen.setup("OK"));
2307 var body = document.getElementById("main-body")!;
2308 zoom_btn = document.getElementById("zoom-btn") as any;
2309 zoom_val = document.getElementById("zoom-val") as any;
2310 links_elem = document.getElementById("links") as any;
2311 ctoken = body.dataset.ctoken!;
2312 us = body.dataset.us!;
2313 gen = +body.dataset.gen!;
2314 let sse_url_prefix = body.dataset.sseUrlPrefix!;
2315 status_node = document.getElementById('status')!;
2316 status_node.innerHTML = 'js-done';
2317 log_elem = document.getElementById("log")!;
2318 logscroll_elem = document.getElementById("logscroll") || log_elem;
2319 let dataload = JSON.parse(body.dataset.load!);
2320 held_surround_colour = dataload.held_surround_colour!;
2321 players = dataload.players!;
2322 delete body.dataset.load;
2323 uos_node = document.getElementById("uos")!;
2324 occregions = wasm_bindgen.empty_region_list();
2326 space = svg_element('space')!;
2327 pieces_marker = svg_element("pieces_marker")!;
2328 defs_marker = svg_element("defs_marker")!;
2329 movehist_start = svg_element('movehist_marker')!;
2330 movehist_end = svg_element('movehist_end')!;
2331 rectsel_path = svg_element('rectsel_path')!;
2332 svg_ns = space.getAttribute('xmlns')!;
2334 for (let uelem = pieces_marker.nextElementSibling! as SVGGraphicsElement;
2335 uelem != defs_marker;
2336 uelem = uelem.nextElementSibling! as SVGGraphicsElement) {
2337 let piece = uelem.dataset.piece!;
2338 let p = JSON.parse(uelem.dataset.info!);
2340 p.delem = piece_element('defs',piece);
2341 p.pelem = piece_element('piece',piece);
2343 occregion_update(piece, p, p); delete p.occregion;
2344 delete uelem.dataset.info;
2346 piece_resolve_special(piece,p);
2347 redisplay_ancillaries(piece,p);
2350 if (test_update_hook == null) test_update_hook = function() { };
2353 last_log_ts = wasm_bindgen.timestamp_abbreviator(dataload.last_log_ts);
2355 for (let ent of dataload.movehist.hist) {
2356 movehist_record(ent);
2359 var es = new EventSource(
2360 sse_url_prefix + "/_/updates?ctoken="+ctoken+'&gen='+gen
2362 es.onmessage = function(event) {
2363 console.log('GOTEVE', event.data);
2367 var [tgen, ms] = JSON.parse(event.data);
2369 k = Object.keys(m)[0];
2375 var s = exc.toString();
2376 string_report_error('exception handling update '
2377 + k + ': ' + JSON.stringify(m) + ': ' + s);
2380 es.addEventListener('commsworking', function(event) {
2381 console.log('GOTDATA', (event as any).data);
2382 status_node.innerHTML = (event as any).data;
2384 es.addEventListener('player-gone', function(event) {
2385 console.log('PLAYER-GONE', event);
2386 status_node.innerHTML = (event as any).data;
2387 add_log_message('<strong>You are no longer in the game</strong>');
2388 space.removeEventListener('mousedown', some_mousedown);
2389 document.removeEventListener('keydown', some_keydown);
2392 es.addEventListener('updates-expired', function(event) {
2393 console.log('UPDATES-EXPIRED', event);
2394 string_report_error('connection to server interrupted too long');
2396 es.onerror = function(e) {
2399 updates_event_source : es,
2400 updates_event_source_ready : es.readyState,
2401 update_oe : (e as any).className,
2403 if (es.readyState == 2) {
2405 reason: "TOTAL SSE FAILURE",
2409 console.log('SSE error event', info);
2412 recompute_keybindings();
2413 space.addEventListener('mousedown', some_mousedown);
2414 space.addEventListener('dragstart', function (e) {
2416 e.stopPropagation();
2418 document.addEventListener('keydown', some_keydown);
2422 type DieSpecialRendering = SpecialRendering & {
2423 cd_path: SVGPathElement,
2424 loaded_ts: DOMHighResTimeStamp,
2425 loaded_remprop: number,
2428 anim_id: number | null,
2430 special_renderings['Die'] = function(piece: PieceId, p: PieceInfo,
2431 s: DieSpecialRendering) {
2432 let cd_path = document.getElementById('def.'+piece+'.die.cd');
2433 if (!cd_path) return;
2435 s.cd_path = cd_path as any as SVGPathElement;
2436 s.loaded_ts = performance.now();
2437 s.loaded_remprop = parseFloat(cd_path.dataset.remprop!)!;
2438 s.total_ms = parseFloat(cd_path.dataset.total_ms!)!;
2439 s.radius = parseFloat(cd_path.dataset.radius!)!;
2441 s.stop = die_rendering_stop as any;
2442 die_request_animation(piece, p, s);
2444 function die_request_animation(piece: PieceId, p: PieceInfo,
2445 s: DieSpecialRendering) {
2446 s.anim_id = window.requestAnimationFrame(
2447 function(ts) { die_render_frame(piece, p, s, ts) }
2450 function die_render_frame(piece: PieceId, p: PieceInfo,
2451 s: DieSpecialRendering, ts: DOMHighResTimeStamp) {
2453 let remprop = s.loaded_remprop - (ts - s.loaded_ts) / s.total_ms;
2454 //console.log('DIE RENDER', piece, s, remprop);
2456 console.log('DIE COMPLETE', piece, s, remprop);
2457 let to_remove: Element = s.cd_path;
2459 let previous = to_remove.previousElementSibling!;
2460 // see dice/overlya-template-extractor
2461 if (to_remove.tagName == 'text') break;
2463 to_remove = previous;
2466 let path_d = wasm_bindgen.die_cooldown_path(s.radius, remprop);
2467 s.cd_path.setAttributeNS(null, "d", path_d);
2468 die_request_animation(piece, p, s);
2471 function die_rendering_stop(piece: PieceId, p: PieceInfo,
2472 s: DieSpecialRendering) {
2473 let anim_id = s.anim_id;
2474 if (anim_id == null) return;
2476 window.cancelAnimationFrame(anim_id);
2479 declare var wasm_input : any;
2480 var wasm_promise : Promise<any>;;
2483 console.log('DOLOAD');
2484 globalinfo_elem = document.getElementById('global-info')!;
2485 layout = globalinfo_elem!.dataset!.layout! as any;
2486 var elem = document.getElementById('loading_token')!;
2487 var ptoken = elem.dataset.ptoken;
2488 xhr_post_then('/_/session/' + layout,
2489 JSON.stringify({ ptoken : ptoken }),
2492 wasm_promise = wasm_input
2493 .then(wasm_bindgen);
2496 function loaded(xhr: XMLHttpRequest){
2497 console.log('LOADED');
2498 var body = document.getElementById('loading_body')!;
2499 wasm_promise.then((got_wasm) => {
2501 body.outerHTML = xhr.response;
2505 let s = exc.toString();
2506 string_report_error_raw('Exception on load, unrecoverable: ' + s);
2511 // todo scroll of log messages to bottom did not always work somehow
2512 // think I have fixed this with approximation