// -*- JavaScript -*- // Copyright 2020-2021 Ian Jackson and contributors to Otter // SPDX-License-Identifier: AGPL-3.0-or-later // There is NO WARRANTY. // elemnts for a piece // // In svg toplevel // // uelem // #use{} // // .piece piece id (static) // container to allow quick movement and hang stuff off // // delem // #defs{} // // // And in each delem // // pelem // #piece{} // .dragraise dragged more than this ? raise to top! // .special enum RenderSpecial // // currently-displayed version of the piece // to allow addition/removal of selected indication // contains 1 or 3 subelements: // one is straight from server and not modified // one is possible // one is possible // // #select{} // generated by server, referenced by JS in pelem for selection // // #def.{}.stuff // generated by server, reserved for Piece trait impl type PieceId = string; type PlayerId = string; type Pos = [number, number]; type Rect = [Pos, Pos]; type ClientSeq = number; type Generation = number; type UoKind = 'Client' | "Global"| "Piece" | "ClientExtra" | "GlobalExtra"; type WhatResponseToClientOp = "Predictable" | "Unpredictable" | "UpdateSvg"; type HeldUsRaising = "NotYet" | "Lowered" | "Raised" type Timestamp = number; // unix time_t, will break in 285My type Layout = 'Portrait' | 'Landscape'; type PieceMoveable = "No" | "IfWresting" | "Yes"; type CompassAngle = number; type FaceId = number; type UoDescription = { kind: UoKind; wrc: WhatResponseToClientOp, def_key: string, opname: string, desc: string, } type UoRecord = UoDescription & { targets: PieceId[] | null, } type ZCoord = string; // On load, starts from SessionPieceLoadJson (Rust-only) // On update, updated field-by-field from PreparedPieceState (Rust&JS) type PieceInfo = { held : PlayerId | null, cseq_main : number | null, cseq_loose: number | null, cseq_updatesvg : number | null, z : ZCoord, zg : Generation, angle: CompassAngle, pinned: boolean, moveable: PieceMoveable, rotateable: boolean, multigrab: boolean, desc: string, uos : UoDescription[], uelem : SVGGraphicsElement, delem : SVGGraphicsElement, pelem : SVGGraphicsElement, queued_moves : number, last_seen_moved : DOMHighResTimeStamp | null, // non-0 means halo'd held_us_inoccult: boolean, held_us_raising: HeldUsRaising, bbox: Rect, drag_delta: number, special: SpecialRendering | null, } type SpecialRendering = { kind: string, stop: SpecialRenderingCallback, } let wasm : InitOutput; var pieces : { [piece: string]: PieceInfo } = Object.create(null); type MessageHandler = (op: Object) => void; type PieceHandler = (piece: PieceId, p: PieceInfo, info: Object) => void; type PieceErrorHandler = (piece: PieceId, p: PieceInfo, m: PieceOpError) => boolean; type SpecialRenderingCallback = (piece: PieceId, p: PieceInfo, s: SpecialRendering) => void; interface DispatchTable { [key: string]: H }; var otter_debug: boolean; // from header var movehist_len_i: number; var movehist_len_max: number; var movehist_lens: number[]; // todo turn all var into let // todo any exceptions should have otter in them or something var globalinfo_elem : HTMLElement; var layout: Layout; var held_surround_colour: string; var general_timeout : number = 10000; var messages : DispatchTable = Object(); var special_renderings : DispatchTable = Object(); var pieceops : DispatchTable = Object(); var update_error_handlers : DispatchTable = Object(); var piece_error_handlers : DispatchTable = Object(); var our_dnd_type = "text/puvnex-game-server-dummy"; var api_queue : [string, Object][] = []; var api_posting = false; var us : PlayerId; var gen : Generation = 0; var cseq : ClientSeq = 0; var ctoken : string; var uo_map : { [k: string]: UoRecord | null } = Object.create(null); var keyops_local : { [opname: string]: (uo: UoRecord) => void } = Object(); var last_log_ts: wasm_bindgen.TimestampAbbreviator; var last_zoom_factor : number = 1.0; var firefox_bug_zoom_factor_compensation : number = 1.0; var test_update_hook : () => void; var svg_ns : string; var space : SVGGraphicsElement; var pieces_marker : SVGGraphicsElement; var defs_marker : SVGGraphicsElement; var movehist_start: SVGGraphicsElement; var movehist_end: SVGGraphicsElement; var rectsel_path: SVGGraphicsElement; var log_elem : HTMLElement; var logscroll_elem : HTMLElement; var status_node : HTMLElement; var uos_node : HTMLElement; var zoom_val : HTMLInputElement; var zoom_btn : HTMLInputElement; var links_elem : HTMLElement; var was_wresting: boolean; var wresting: boolean; var occregions: wasm_bindgen.RegionList; let special_count: number | null; var movehist_gen: number = 0; const MOVEHIST_ENDS = 2.5; const SPECIAL_MULTI_DELTA_EACH = 3; const SPECIAL_MULTI_DELTA_MAX = 18; type PaneName = string; const pane_keys : { [key: string]: PaneName } = { "H" : "help", "U" : "players", "B" : "bundles", }; const uo_kind_prec : { [kind: string]: number } = { 'GlobalExtra' : 50, 'Client' : 70, 'Global' : 100, 'Piece' : 200, 'ClientExtra' : 500, } type PlayerInfo = { dasharray : string, nick: string, } var players : { [player: string]: PlayerInfo }; type MovementRecord = { // for yellow halo, unrelasted to movehist piece: PieceId, p: PieceInfo, this_motion: DOMHighResTimeStamp, } var movements : MovementRecord[] = []; function xhr_post_then(url : string, data: string, good : (xhr: XMLHttpRequest) => void) { var xhr : XMLHttpRequest = new XMLHttpRequest(); xhr.onreadystatechange = function(){ if (xhr.readyState != XMLHttpRequest.DONE) { return; } if (xhr.status != 200) { xhr_report_error(xhr); } else { good(xhr); } }; xhr.timeout = general_timeout; xhr.open('POST',url); xhr.setRequestHeader('Content-Type','application/json'); xhr.send(data); } function xhr_report_error(xhr: XMLHttpRequest) { json_report_error({ statusText : xhr.statusText, responseText : xhr.responseText, }); } function json_report_error(error_for_json: Object) { let error_message = JSON.stringify(error_for_json); string_report_error(error_message); } function string_report_error(error_message: String) { string_report_error_raw('Error (reloading may help?): ' + error_message) } function string_report_error_raw(error_message: String) { let errornode = document.getElementById('error')!; errornode.textContent += '\n' + error_message; console.error("ERROR reported via log", error_message); // todo want to fix this for at least basic game reconfigs, auto-reload? } function api_immediate(meth: string, data: Object) { api_queue.push([meth, data]); api_check(); } function api_delay(meth: string, data: Object) { if (api_queue.length==0) window.setTimeout(api_check, 10); api_queue.push([meth, data]); } function api_check() { if (api_posting) { return; } if (!api_queue.length) { test_update_hook(); return; } do { var [meth, data] = api_queue.shift()!; if (meth != 'm') break; let piece = (data as any).piece; let p = pieces[piece]; if (p == null) break; p.queued_moves--; if (p.queued_moves == 0) break; } while (api_queue.length); api_posting = true; xhr_post_then('/_/api/'+meth, JSON.stringify(data), api_posted); } function api_posted() { api_posting = false; api_check(); } function api_piece_x(f: (meth: string, payload: Object) => void, loose: boolean, meth: string, piece: PieceId, p: PieceInfo, op: Object) { clear_halo(piece,p); cseq += 1; if (loose) { p.cseq_loose = cseq; } else { p.cseq_main = cseq; p.cseq_loose = null; } f(meth, { ctoken : ctoken, piece : piece, gen : gen, cseq : cseq, op : op, loose: loose, }) } function api_piece(meth: string, piece: PieceId, p: PieceInfo, op: Object) { api_piece_x(api_immediate, false,meth, piece, p, op); } function svg_element(id: string): SVGGraphicsElement | null { let elem = document.getElementById(id); return elem as unknown as (SVGGraphicsElement | null); } function piece_element(base: string, piece: PieceId): SVGGraphicsElement | null { return svg_element(base+piece); } function piece_moveable(p: PieceInfo) { return p.moveable == 'Yes' || p.moveable == 'IfWresting' && wresting; } function treat_as_pinned(p: { pinned: boolean }): boolean { return p.pinned && !wresting; } function pinned_message_for_log(p: PieceInfo): string { return 'That piece ('+p.desc+') is pinned to the table.'; } // ----- key handling ----- function recompute_keybindings() { uo_map = Object.create(null); let all_targets = []; // Types here are a little messy function prep_add_uo(uo: UoDescription) : null | { targets: PieceId[] | null } { let currently = uo_map[uo.def_key]; if (currently === null) return null; if (currently !== undefined) { if (currently.opname != uo.opname) { uo_map[uo.def_key] = null; return null; } } else { currently = { targets: [], ...uo }; uo_map[uo.def_key] = currently; } currently.desc = currently.desc < uo.desc ? currently.desc : uo.desc; return currently as unknown as any; }; for (let piece of Object.keys(pieces)) { let p = pieces[piece]; if (p.held != us) continue; all_targets.push(piece); for (var uo of p.uos) { let currently = prep_add_uo(uo); if (currently) { currently.targets!.push(piece); } } } all_targets.sort(pieceid_z_cmp); let add_uo = function(targets: PieceId[] | null, uo: UoDescription) { let currently = prep_add_uo(uo); if (currently) { currently.targets = targets; }; }; if (all_targets.length) { let got_rotateable = false; for (let t of all_targets) { if (pieces[t]!.rotateable) got_rotateable = true; } if (got_rotateable) { add_uo(all_targets, { def_key: 'l', kind: 'Client', wrc: 'Predictable', opname: "left", desc: "rotate left", }); add_uo(all_targets, { def_key: 'r', kind: 'Client', wrc: 'Predictable', opname: "right", desc: "rotate right", }); } add_uo(all_targets, { def_key: 'b', kind: 'Client', wrc: 'Predictable', opname: "lower", desc: "send to bottom (below other pieces)", }); add_uo(all_targets, { def_key: 't', kind: 'Client', wrc: 'Predictable', opname: "raise", desc: "raise to top", }); } if (all_targets.length) { let got = 0; for (let t of all_targets) { got |= 1 << Number(pieces[t]!.pinned); } if (got == 1) { add_uo(all_targets, { def_key: 'P', kind: 'ClientExtra', opname: 'pin', desc: 'Pin to table', wrc: 'Predictable', }); } else if (got == 2) { add_uo(all_targets, { def_key: 'P', kind: 'ClientExtra', opname: 'unpin', desc: 'Unpin from table', wrc: 'Predictable', }); } } add_uo(null, { def_key: wresting ? 'W SPC' /* won't match, handle ad-hoc */ : 'W', kind: 'ClientExtra', opname: 'wrest', desc: wresting ? 'Exit wresting mode' : 'Enter wresting mode', wrc: 'Predictable', }); if (special_count != null) { let desc; if (special_count == 0) { desc = 'select bottommost'; } else { desc = `select ${special_count}`; } desc = `cancel ${desc}`; add_uo(null, { def_key: 'SPC', // won't match key event; we handle this ad-hoc kind: 'ClientExtra', opname: 'cancel-special', desc: desc, wrc: 'Predictable', }); } add_uo(null, { def_key: 'h', kind: 'ClientExtra', opname: 'motion-hint-history', desc: 'Recent history display', wrc: 'Predictable', }); var uo_keys = Object.keys(uo_map); uo_keys.sort(function (ak,bk) { let a = uo_map[ak]; let b = uo_map[bk]; if (a==null || b==null) return ( ( (!!a) as any ) - ( (!!b) as any ) ); return uo_kind_prec[a.kind] - uo_kind_prec[b.kind] || ak.localeCompare(bk); }); let mid_elem = null; for (let celem = uos_node.firstElementChild; celem != null; celem = nextelem) { var nextelem = celem.nextElementSibling; let cid = celem.getAttribute("id"); if (cid == "uos-mid") mid_elem = celem; else if (celem.getAttribute("class") == 'uos-mid') { } else celem.remove(); } for (var kk of uo_keys) { let uo = uo_map[kk]; if (!uo) continue; let prec = uo_kind_prec[uo.kind]; let ent = document.createElement('div'); ent.innerHTML = '' + kk + ' ' + uo.desc; if (prec < 400) { ent.setAttribute('class','uokey-l'); uos_node.insertBefore(ent, mid_elem); } else { ent.setAttribute('class','uokey-r'); uos_node.appendChild(ent); } } } function some_keydown(e: KeyboardEvent) { // https://developer.mozilla.org/en-US/docs/Web/API/Document/keydown_event // says to do this, something to do with CJK composition. // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent // says that keyCode is deprecated // my tsc says this isComposing thing doesn't exist. wat. if ((e as any).isComposing /* || e.keyCode === 229 */) return; if (e.ctrlKey || e.altKey || e.metaKey) return; if (e.target) { // someone else is dealing with it ? let tag = (e.target as HTMLElement).tagName; if (tag == 'INPUT') return; } let y = function() { e.preventDefault(); e.stopPropagation(); } let pane = pane_keys[e.key]; if (pane) { y(); return pane_switch(pane); } let special_count_key = parseInt(e.key); if (isFinite(special_count_key)) { y(); if (special_count == null) special_count = 0; special_count *= 10; special_count += special_count_key; special_count %= 100000; mousecursor_etc_reupdate(); return; } if (e.key == ' ' || (e.key == 'W' && wresting)) { y(); special_count = null; wresting = false; mousecursor_etc_reupdate(); return; } if (e.key == 'Backspace') { if (special_count == null) { wresting = false; } else if (special_count >= 10) { special_count = Math.round(special_count / 10 - .45); } else { special_count = null; } mousecursor_etc_reupdate(); return; } let uo = uo_map[e.key]; if (uo === undefined || uo === null) return; y(); console.log('KEY UO', e, uo); if (uo.kind == 'Client' || uo.kind == 'ClientExtra') { let f = keyops_local[uo.opname]; f(uo); return; } if (!(uo.kind == 'Global' || uo.kind == 'GlobalExtra' || uo.kind == 'Piece')) throw 'bad kind '+uo.kind; for (var piece of uo.targets!) { let p = pieces[piece]!; api_piece('k', piece, p, { opname: uo.opname, wrc: uo.wrc }); if (uo.wrc == 'UpdateSvg') { // No UpdateSvg is loose, so no need to check p.cseq_loose p.cseq_updatesvg = p.cseq_main; redisplay_ancillaries(piece,p); } } } function pane_switch(newpane: PaneName) { let new_e; for (;;) { new_e = document.getElementById('pane_' + newpane)!; let style = new_e.getAttribute('style'); if (style || newpane == 'help') break; newpane = 'help'; } for (let old_e = new_e.parentElement!.firstElementChild; old_e; old_e = old_e.nextElementSibling) { old_e.setAttribute('style','display: none;'); } new_e.removeAttribute('style'); } function mousecursor_etc_reupdate() { let style_elem = document.getElementById("space-cursor-style")!; let style_text; let svg; let xy; let path = 'stroke-linecap="square" d="M -10 -10 10 10 M 10 -10 -10 10"'; document.getElementById('wresting-warning')!.innerHTML = !wresting ? "" : " (wresting mode!)"; if (wresting != was_wresting) { ungrab_all(); was_wresting = wresting; } if (wresting) { let text; if (special_count == null) { text = "WREST"; } else if (special_count == 0) { text = "W v"; } else { text = "W " + special_count; } xy = '60 15'; svg = ` ${text} `; } else if (special_count == null) { } else { if (special_count != 0) { let text_len = special_count.toString().length; let text_x = text_len <= 3 ? 0 : -15; let text_size = text_len <= 3 ? 50 : 45 * (4/text_len); xy = '15 50'; svg = ` ${special_count} `; } else { let path = 'stroke-linecap="square" d="M -10 -10 0 0 10 -10 M 0 0 0 -20"'; xy = '15 30'; svg = ` `; } } // Empirically, setting this to '' and then back to the SVG data // seems to cause Firefox to update it more promptly. style_elem.innerHTML = ''; if (svg !== undefined) { let svg_data = btoa(svg); style_text = `svg[id=space] { cursor: url(data:image/svg+xml;base64,${svg_data}) ${xy}, auto; }`; style_elem.innerHTML = style_text; } recompute_keybindings(); } keyops_local['left' ] = function (uo: UoRecord) { rotate_targets(uo, +1); } keyops_local['right'] = function (uo: UoRecord) { rotate_targets(uo, -1); } function rotate_targets(uo: UoRecord, dangle: number): boolean { for (let piece of uo.targets!) { let p = pieces[piece]!; if (!p.rotateable) continue; p.angle += dangle + 8; p.angle %= 8; let transform = wasm_bindgen.angle_transform(p.angle); p.pelem.setAttributeNS(null,'transform',transform); api_piece('rotate', piece,p, p.angle); } recompute_keybindings(); return true; } // ----- lower ----- type LowerTodoItem = { piece: PieceId, p: PieceInfo, heavy: boolean, }; type LowerTodoList = { [piece: string]: LowerTodoItem }; keyops_local['lower'] = function (uo: UoRecord) { lower_targets(uo); } function lower_heavy(p: PieceInfo): boolean { return wresting || p.pinned || p.moveable == "No"; } function lower_targets(uo: UoRecord): boolean { let targets_todo : LowerTodoList = Object.create(null); for (let piece of uo.targets!) { let p = pieces[piece]!; let heavy = lower_heavy(p); targets_todo[piece] = { p, piece, heavy, }; } let problem = lower_pieces(targets_todo); if (problem !== null) { add_log_message('Cannot lower: ' + problem); return false; } return true; } function lower_pieces(targets_todo: LowerTodoList): string | null { // This is a bit subtle. We don't want to lower below heavy pieces // (unless we are heavy too, or the user is wresting). But maybe // the heavy pieces aren't already at the bottom. For now we will // declare that all heavy pieces "should" be below all light // ones. Not as an invariant, but as a thing we will do here to try // to make a sensible result. We implement this as follows: if we // find heavy pieces above light pieces, we move those heavy // pieces to the bottom too, just below us, preserving their // relative order. // // Disregarding heavy targets: // // Z // Z // topmost light target * // B ( // B light non-target // B | light target * // B | heavy non-target, mis-stacked * // B )* // B // bottommost light non-target // if that is below topmost light target // <- tomove_light: insert targets from * here Q -> // <- tomove_misstacked: insert non-targets from * here Q -> // <- heavy non-targets with clashing Z Coords X -> // A // A heavy non-targets (nomove_heavy) // <- tomove_heavy: insert all heavy targets here P -> // // When wresting, treat all targets as heavy. type Entry = { piece: PieceId, p: PieceInfo, }; // bottom of the stack order first let tomove_light : Entry[] = []; let tomove_misstacked : Entry[] = []; let nomove_heavy : Entry[] = []; let tomove_heavy : Entry[] = []; // A B Z let q_z_top : ZCoord | null = null; // null some some let n_targets_todo_light = 0; // 0 let any_targets = false; for (const piece of Object.keys(targets_todo)) { any_targets = true; let p = targets_todo[piece]; if (!p.heavy) n_targets_todo_light++; } if (!any_targets) return 'Nothing to lower!'; let walk = pieces_marker; for (;;) { // starting at the bottom of the stack order if (Object.keys(targets_todo).length == 0 && q_z_top !== null) { // no targets left, state Z, we can stop now console.log('LOWER STATE Z FINISHED'); break; } let new_walk = walk.nextElementSibling; if (new_walk == null) { console.log('LOWER WALK NO SIBLING!'); break; } walk = new_walk as SVGGraphicsElement; let piece = walk.dataset.piece; if (piece == null) { console.log('LOWER WALK REACHED TOP'); break; } let p = pieces[piece]!; let todo = targets_todo[piece]; if (todo) { let xst = ''; if (q_z_top === null && !todo.heavy) { q_z_top = p.z; xst = 'STATE -> B'; } console.log('LOWER WALK', piece, 'TODO', todo.heavy ? "H" : "_", xst); delete targets_todo[piece]; if (!todo.heavy) n_targets_todo_light--; (todo.heavy ? tomove_heavy : tomove_light).push(todo); continue; } let p_heavy = lower_heavy(p); if (q_z_top === null) { // state A if (!p_heavy) { console.log('LOWER WALK', piece, 'STATE A -> Z'); q_z_top = p.z; } else { console.log('LOWER WALK', piece, 'STATE A'); nomove_heavy.push({ p, piece }); } continue; } // state B if (p_heavy) { console.log('LOWER WALK', piece, 'STATE B MIS-STACKED'); tomove_misstacked.push({ p, piece }); } else { console.log('LOWER WALK', piece, 'STATE B'); } } if (q_z_top === null) { // Somehow we didn't find the top of Q, so we didn't meet any // targets. (In the walk loop, we always set q_z_top if todo.) q_z_top = tomove_misstacked.length ? tomove_misstacked[0].p.z : tomove_light .length ? tomove_light [0].p.z : tomove_heavy [0].p.z; } while (nomove_heavy.length && (tomove_light.length || tomove_misstacked.length) && nomove_heavy[nomove_heavy.length-1].p.z == q_z_top) { // Yowzer. We have to reset the Z coordinates on these heavy // pieces, whose Z coordinate is the same as the stuff we are not // touching, because otherwise there is no gap. // // Treating them as misstacked instead is sufficient, provided // we put them at the front (bottom end) of the misstacked list. // // This is X in the chart. // let restack = nomove_heavy.pop()!; console.log('LOWER CLASHING Z - RESTACKING', restack); tomove_misstacked.unshift(restack); } type PlanEntry = { content: Entry[], // bottom to top z_top: ZCoord, z_bot: ZCoord | null, }; let plan : PlanEntry[] = []; console.log('LOWER PARTQ X', tomove_misstacked); console.log('LOWER PARTQ L', tomove_light); console.log('LOWER PARTP H', tomove_heavy); let partQ = tomove_misstacked.concat(tomove_light); let partP = tomove_heavy; if (nomove_heavy.length == 0) { plan.push({ content: partP.concat(partQ), z_top: q_z_top, z_bot : null, }); } else { plan.push({ content: partQ, z_top: q_z_top, z_bot: nomove_heavy[nomove_heavy.length-1].p.z, }, { content: partP, z_top: nomove_heavy[0].p.z, z_bot: null, }); } console.log('LOWER PLAN', plan); for (const pe of plan) { for (const e of pe.content) { if (e.p.held != null && e.p.held != us) { return "lowering would disturb a piece held by another player"; } } } for (const pe of plan) { let z_top = pe.z_top; let z_bot = pe.z_bot; if (! pe.content.length) continue; if (z_bot == null) { let first_z = pe.content[0].p.z; if (z_top >= first_z) z_top = first_z; } let zrange = wasm_bindgen.range(z_bot, z_top, pe.content.length); console.log('LOQER PLAN PE', pe, z_bot, z_top, pe.content.length, zrange.debug()); for (const e of pe.content) { let p = e.p; p.held_us_raising = "Lowered"; piece_set_zlevel(e.piece, p, (oldtop_piece) => { let z = zrange.next(); p.z = z; api_piece("setz", e.piece, e.p, { z }); }); } } return null; } keyops_local['wrest'] = function (uo: UoRecord) { wresting = !wresting; mousecursor_etc_reupdate(); } keyops_local['motion-hint-history'] = function (uo: UoRecord) { movehist_len_i ++; movehist_len_i %= movehist_lens.length; movehist_revisible(); } keyops_local['pin' ] = function (uo) { if (!lower_targets(uo)) return; pin_unpin(uo, true); } keyops_local['unpin'] = function (uo) { pin_unpin(uo, false); } function pin_unpin(uo: UoRecord, newpin: boolean) { for (let piece of uo.targets!) { let p = pieces[piece]!; p.pinned = newpin; api_piece('pin', piece,p, newpin); redisplay_ancillaries(piece,p); } recompute_keybindings(); } // ----- raising ----- keyops_local['raise'] = function (uo: UoRecord) { raise_targets(uo); } function raise_targets(uo: UoRecord) { let any = false; for (let piece of uo.targets!) { let p = pieces[piece]!; if (p.pinned || !piece_moveable(p)) continue; any = true; piece_raise(piece, p, "NotYet"); } if (!any) { add_log_message('No pieces could be raised.'); } } function piece_raise(piece: PieceId, p: PieceInfo, new_held_us_raising: HeldUsRaising, implement: (piece: PieceId, p: PieceInfo, z: ZCoord) => void = function(piece: PieceId, p: PieceInfo, z: ZCoord) { api_piece("setz", piece,p, { z: z }); }) { p.held_us_raising = new_held_us_raising; piece_set_zlevel(piece,p, (oldtop_piece) => { let oldtop_p = pieces[oldtop_piece]!; let z = wasm_bindgen.increment(oldtop_p.z); p.z = z; implement(piece,p,z); }); } // ----- clicking/dragging pieces ----- type DragInfo = { piece : PieceId, dox : number, doy : number, } enum DRAGGING { // bitmask NO = 0x00, MAYBE_GRAB = 0x01, MAYBE_UNGRAB = 0x02, YES = 0x04, RAISED = 0x08, }; var drag_pieces : DragInfo[] = []; var dragging = DRAGGING.NO; var dcx : number | null; var dcy : number | null; const DRAGTHRESH = 5; let rectsel_start: Pos | null; let rectsel_shifted: boolean | null; let rectsel_started_on_whynot: string | null; let rectsel_started_on_grab: PieceId | null; const RECTSELTHRESH = 5; function piece_xy(p: PieceInfo): Pos { return [ parseFloat(p.uelem.getAttributeNS(null,"x")!), parseFloat(p.uelem.getAttributeNS(null,"y")!) ]; } function drag_start_prepare(new_dragging: DRAGGING) { dragging = new_dragging; let spos_map = Object.create(null); for (let piece of Object.keys(pieces)) { let p = pieces[piece]!; if (p.held != us) continue; let spos = piece_xy(p); let sposk = `${spos[0]} ${spos[1]}`; if (spos_map[sposk] === undefined) spos_map[sposk] = [spos, []]; spos_map[sposk][1].push([spos, piece,p]); } for (let sposk of Object.keys(spos_map)) { let [[dox, doy], ents] = spos_map[sposk]; for (let i=0; i boolean ): MouseFindClicked { let clicked: PieceId[]; let held: string | null; let pinned = false; let already_count = 0; clicked = []; let uelem = defs_marker; while (wanted == null || (clicked.length + already_count) < wanted) { let i = clicked.length; uelem = uelem.previousElementSibling as any; if (uelem == pieces_marker) { if (wanted != null) { add_log_message(`Not enough pieces! Stopped after ${i}.`); return null; } break; } let piece = uelem.dataset.piece!; function is_already() { if (note_already != null) { already_count++; note_already[piece] = true; } } let p = pieces[piece]; if (treat_as_pinned(p)) continue; if (p.held && p.held != us && !wresting) continue; if (i > 0 && !piece_moveable(p)) continue; if (!predicate(p)) { continue; } if (p.pinned) pinned = true; if (i == 0) { held = p.held; if (held == us && !allow_for_deselect) held = null; } if (held! == us) { // user is going to be deselecting if (p.held != us) { // skip ones we don't have is_already(); continue; } } else { // user is going to be selecting if (p.held == us) { is_already(); continue; // skip ones we have already } else if (p.held == null) { } else { held = p.held; // wrestish } } clicked.push(piece); } if (clicked.length == 0) return null; else return { clicked, held: held!, pinned: pinned! }; } function mouse_find_lowest(e: MouseEvent) { let clickpos = mouseevent_pos(e); let uelem = pieces_marker; for (;;) { uelem = uelem.nextElementSibling as any; if (uelem == defs_marker) break; let piece = uelem.dataset.piece!; let p = pieces[piece]!; if (p_bbox_contains(p, clickpos)) { return mouse_clicked_one(piece, p); } } return null; } function mouse_find_clicked(e: MouseEvent, target: SVGGraphicsElement, piece: PieceId, count_allow_for_deselect: boolean, note_already: PieceSet | null, ): MouseFindClicked { let p = pieces[piece]!; if (special_count == null) { return mouse_clicked_one(piece, p); } else if (special_count == 0) { return mouse_find_lowest(e); } else { // special_count > 0 if (p.multigrab && !wresting) { let clicked = mouse_clicked_one(piece, p); if (clicked) clicked.multigrab = special_count; return clicked; } else { if (special_count > 99) { add_log_message( `Refusing to try to select ${special_count} pieces (max is 99)`); return null; } let clickpos = mouseevent_pos(e); return mouse_find_predicate( special_count, count_allow_for_deselect, note_already, function(p) { return p_bbox_contains(p, clickpos); } ) } } } function drag_mousedown(e : MouseEvent, shifted: boolean) { let target = e.target as SVGGraphicsElement; // we check this just now! let piece: PieceId | undefined = target.dataset.piece; rectsel_started_on_whynot = null; rectsel_started_on_grab = null; if (piece) { let p = pieces[piece]!; if (treat_as_pinned(p!)) { rectsel_started_on_whynot = pinned_message_for_log(p!); piece = undefined; console.log('mousedown pinned'); } if (special_count === null && !wresting && !piece_moveable(p)) { rectsel_started_on_grab = piece!; piece = undefined; console.log('mousedown unmoveable'); } } if (!piece) { console.log('mousedown rectsel'); rectsel_start = mouseevent_pos(e); rectsel_shifted = shifted; window.addEventListener('mousemove', rectsel_mousemove, true); window.addEventListener('mouseup', rectsel_mouseup, true); return; } let note_already = shifted ? null : Object.create(null); let c = mouse_find_clicked(e, target, piece, false, note_already); if (c == null) return; special_count = null; mousecursor_etc_reupdate(); drag_cancel(); mouseclick_core(c, shifted, note_already); dcx = e.clientX; dcy = e.clientY; window.addEventListener('mousemove', drag_mousemove, true); window.addEventListener('mouseup', drag_mouseup, true); } // Mostly, run on mousedown. // Sometimes run on mouseup, if we decided that the user might be // intending a drag instead. function mouseclick_core(c: MouseFoundClicked, shifted: boolean, note_already: PieceSet | null) { let held = c.held; let clicked = c.clicked; let multigrab = c.multigrab; drag_pieces = []; if (held == us && multigrab == null) { if (shifted) { ungrab_clicked(clicked); return; } drag_start_prepare(DRAGGING.MAYBE_UNGRAB); } else if (held == null || wresting) { if (!shifted) { ungrab_all_except(note_already); } if (treat_as_pinned(c)) { add_log_message(pinned_message_for_log(pieces[c.clicked[0]!]!)); return; } grab_clicked(clicked, !wresting, multigrab); drag_start_prepare(DRAGGING.MAYBE_GRAB); } else { add_log_message('That piece is held by another player.'); return; } } function mouseevent_pos(e: MouseEvent): Pos { let ctm = space.getScreenCTM()!; let px = (e.clientX - ctm.e)/(ctm.a * firefox_bug_zoom_factor_compensation); let py = (e.clientY - ctm.f)/(ctm.d * firefox_bug_zoom_factor_compensation); let pos: Pos = [px, py]; console.log('mouseevent_pos', pos); return pos; } function p_bbox_contains(p: PieceInfo, test: Pos) { let ctr = piece_xy(p); for (let i of [0,1]) { let offset = test[i] - ctr[i]; if (offset < p.bbox[0][i] || offset > p.bbox[1][i]) return false; } return true; } function do_ungrab_n(todo: [PieceId, PieceInfo][]) { function sort_with(a: [PieceId, PieceInfo], b: [PieceId, PieceInfo]): number { return piece_z_cmp(a[1], b[1]); } todo.sort(sort_with); for (let [tpiece, tp] of todo) { do_ungrab_1(tpiece, tp); } } function ungrab_all_except(dont: PieceSet | null) { let todo: [PieceId, PieceInfo][] = []; for (let tpiece of Object.keys(pieces)) { if (dont && dont[tpiece]) continue; let tp = pieces[tpiece]!; if (tp.held == us) { todo.push([tpiece, tp]); } } do_ungrab_n(todo); } function ungrab_all() { ungrab_all_except(null); } function set_grab_us(piece: PieceId, p: PieceInfo) { p.held = us; p.held_us_raising = "NotYet"; p.drag_delta = 0; redisplay_ancillaries(piece,p); recompute_keybindings(); } function do_ungrab_1(piece: PieceId, p: PieceInfo) { let autoraise = p.held_us_raising == "Raised"; p.held = null; p.held_us_raising = "NotYet"; p.drag_delta = 0; redisplay_ancillaries(piece,p); recompute_keybindings(); api_piece('ungrab', piece,p, { autoraise }); } function clear_halo(piece: PieceId, p: PieceInfo) { let was = p.last_seen_moved; p.last_seen_moved = null; if (was) redisplay_ancillaries(piece,p); } function ancillary_node(piece: PieceId, stroke: string): SVGGraphicsElement { var nelem = document.createElementNS(svg_ns,'use'); nelem.setAttributeNS(null,'href','#surround'+piece); nelem.setAttributeNS(null,'stroke',stroke); nelem.setAttributeNS(null,'fill','none'); return nelem as any; } function redisplay_ancillaries(piece: PieceId, p: PieceInfo) { let href = '#surround'+piece; console.log('REDISPLAY ANCILLARIES',href); for (let celem = p.pelem.firstElementChild; celem != null; celem = nextelem) { var nextelem = celem.nextElementSibling let thref = celem.getAttributeNS(null,"href"); if (thref == href) { celem.remove(); } } let halo_colour = null; if (p.cseq_updatesvg != null) { halo_colour = 'purple'; } else if (p.last_seen_moved != null) { halo_colour = 'yellow'; } else if (p.held != null && p.pinned) { halo_colour = '#8cf'; } if (halo_colour != null) { let nelem = ancillary_node(piece, halo_colour); if (p.held != null) { // value 2ps is also in src/pieces.rs SELECT_STROKE_WIDTH nelem.setAttributeNS(null,'stroke-width','2px'); } p.pelem.prepend(nelem); } if (p.held != null) { let da = null; if (p.held != us) { da = players[p.held!]!.dasharray; } else { let [px, py] = piece_xy(p); let inoccult = occregions.contains_pos(px, py); p.held_us_inoccult = inoccult; if (inoccult) { da = "0.9 0.6"; // dotted dasharray } } let nelem = ancillary_node(piece, held_surround_colour); if (da !== null) { nelem.setAttributeNS(null,'stroke-dasharray',da); } p.pelem.appendChild(nelem); } } function drag_mousemove(e: MouseEvent) { var ctm = space.getScreenCTM()!; var ddx = (e.clientX - dcx!)/(ctm.a * firefox_bug_zoom_factor_compensation); var ddy = (e.clientY - dcy!)/(ctm.d * firefox_bug_zoom_factor_compensation); var ddr2 = ddx*ddx + ddy*ddy; if (!(dragging & DRAGGING.YES)) { if (ddr2 > DRAGTHRESH) { for (let dp of drag_pieces) { let tpiece = dp.piece; let tp = pieces[tpiece]!; if (tp.moveable == "Yes") { continue; } else if (tp.moveable == "IfWresting") { if (wresting) continue; add_log_message( `That piece (${tp.desc}) can only be moved when Wresting.`); } else { add_log_message( `That piece (${tp.desc}) cannot be moved at the moment.`); } return ddr2; } dragging |= DRAGGING.YES; } } //console.log('mousemove', ddx, ddy, dragging); if (dragging & DRAGGING.YES) { console.log('DRAG PIECES',drag_pieces); for (let dp of drag_pieces) { console.log('DRAG PIECES PIECE',dp); let tpiece = dp.piece; let tp = pieces[tpiece]!; var x = Math.round(dp.dox + ddx); var y = Math.round(dp.doy + ddy); let need_redisplay_ancillaries = ( tp.held == us && occregions.contains_pos(x,y) != tp.held_us_inoccult ); piece_set_pos_core(tp, x, y); tp.queued_moves++; api_piece_x(api_delay, false, 'm', tpiece,tp, [x, y] ); if (need_redisplay_ancillaries) redisplay_ancillaries(tpiece, tp); } if (!(dragging & DRAGGING.RAISED)) { sort_drag_pieces(); for (let dp of drag_pieces) { let piece = dp.piece; let p = pieces[piece]!; if (p.held_us_raising == "Lowered") continue; let dragraise = +p.pelem.dataset.dragraise!; if (dragraise > 0 && ddr2 >= dragraise*dragraise) { dragging |= DRAGGING.RAISED; console.log('CHECK RAISE ', dragraise, dragraise*dragraise, ddr2); piece_raise(piece,p,"Raised"); } } } } return ddr2; } function sort_drag_pieces() { function sort_with(a: DragInfo, b: DragInfo): number { return pieceid_z_cmp(a.piece, b.piece); } drag_pieces.sort(sort_with); } function drag_mouseup(e: MouseEvent) { console.log('mouseup', dragging); let ddr2 : number = drag_mousemove(e); drag_end(); } function drag_end() { if (dragging == DRAGGING.MAYBE_UNGRAB || (dragging & ~DRAGGING.RAISED) == (DRAGGING.MAYBE_GRAB | DRAGGING.YES)) { sort_drag_pieces(); for (let dp of drag_pieces) { let piece = dp.piece; let p = pieces[piece]!; do_ungrab_1(piece,p); } } drag_cancel(); } function drag_cancel() { window.removeEventListener('mousemove', drag_mousemove, true); window.removeEventListener('mouseup', drag_mouseup, true); dragging = DRAGGING.NO; drag_pieces = []; } function rectsel_nontrivial_pos2(e: MouseEvent): Pos | null { let pos2 = mouseevent_pos(e); let d2 = 0; for (let i of [0,1]) { let d = pos2[i] - rectsel_start![i]; d2 += d*d; } return d2 > RECTSELTHRESH*RECTSELTHRESH ? pos2 : null; } function rectsel_mousemove(e: MouseEvent) { let pos2 = rectsel_nontrivial_pos2(e); let path; if (pos2 == null) { path = ""; } else { let pos1 = rectsel_start!; path = `M ${ pos1 [0]} ${ pos1 [1] } ${ pos2 [0]} ${ pos1 [1] } M ${ pos1 [0]} ${ pos2 [1] } ${ pos2 [0]} ${ pos2 [1] } M ${ pos1 [0]} ${ pos1 [1] } ${ pos1 [0]} ${ pos2 [1] } M ${ pos2 [0]} ${ pos1 [1] } ${ pos2 [0]} ${ pos2 [1] }`; } rectsel_path.firstElementChild!.setAttributeNS(null,'d',path); } function rectsel_mouseup(e: MouseEvent) { console.log('rectsel mouseup'); window.removeEventListener('mousemove', rectsel_mousemove, true); window.removeEventListener('mouseup', rectsel_mouseup, true); rectsel_path.firstElementChild!.setAttributeNS(null,'d',''); let pos2 = rectsel_nontrivial_pos2(e); if (pos2 == null) { // clicked not on an unpinned piece, and didn't drag if (rectsel_started_on_whynot) { add_log_message(rectsel_started_on_whynot); } if (rectsel_started_on_grab) { let p = pieces[rectsel_started_on_grab]; mouseclick_core({ clicked: [rectsel_started_on_grab], held: p.held, pinned: treat_as_pinned(p), multigrab: undefined, }, e.shiftKey, null); return; } special_count = null; mousecursor_etc_reupdate(); // we'll bail in a moment, after possibly unselecting things } let note_already = Object.create(null); let c = null; if (pos2 != null) { if (special_count != null && special_count == 0) { add_log_message(`Cannot drag-select lowest.`); return; } let tl = [0,0]; let br = [0,0]; for (let i of [0,1]) { tl[i] = Math.min(rectsel_start![i], pos2[i]); br[i] = Math.max(rectsel_start![i], pos2[i]); } c = mouse_find_predicate( special_count, rectsel_shifted!, note_already, function(p: PieceInfo) { let pp = piece_xy(p); for (let i of [0,1]) { if (pp[i] < tl[i] || pp[i] > br[i]) return false; } return true; } ); } if (!c) { // clicked not on a piece, didn't end up selecting anything // either because drag region had nothing in it, or special // failed, or some such. if (!rectsel_shifted) { let mr; while (mr = movements.pop()) { mr.p.last_seen_moved = null; redisplay_ancillaries(mr.piece, mr.p); } ungrab_all(); } return; } // did the special special_count = null; mousecursor_etc_reupdate(); if (rectsel_shifted && c.held == us) { ungrab_clicked(c.clicked); return; } else { if (!rectsel_shifted) { ungrab_all_except(note_already); } grab_clicked(c.clicked, false, undefined); } } // ----- general ----- type PlayersUpdate = { new_info_pane: string }; messages.SetPlayer = function (j: { player: string, data: PlayerInfo } & PlayersUpdate) { players[j.player] = j.data; player_info_pane_set(j); } messages.RemovePlayer = function (j: { player: string } & PlayersUpdate ) { delete players[j.player]; player_info_pane_set(j); } function player_info_pane_set(j: PlayersUpdate) { document.getElementById('player_list')! .innerHTML = j.new_info_pane; } messages.UpdateBundles = function (j: { new_info_pane: string }) { document.getElementById('bundle_list')! .innerHTML = j.new_info_pane; } messages.SetTableSize = function ([x, y]: [number, number]) { function set_attrs(elem: Element, l: [string,string][]) { for (let a of l) { elem.setAttributeNS(null,a[0],a[1]); } } let rect = document.getElementById('table_rect')!; set_attrs(space, wasm_bindgen.space_table_attrs(x, y)); set_attrs(rect, wasm_bindgen.space_table_attrs(x, y)); } messages.SetTableColour = function (c: string) { let rect = document.getElementById('table_rect')!; rect.setAttributeNS(null, 'fill', c); } messages.SetLinks = function (msg: string) { if (msg.length != 0 && layout == 'Portrait') { msg += " |"; } links_elem.innerHTML = msg } // ---------- movehist ---------- type MoveHistEnt = { held: PlayerId, posx: [MoveHistPosx, MoveHistPosx], diff: { 'Moved': { d: number } }, } type MoveHistPosx = { pos: Pos, angle: CompassAngle, facehint: FaceId | null, } messages.MoveHistEnt = movehist_record; messages.MoveHistClear = function() { movehist_revisible_custmax(0); } function movehist_record(ent: MoveHistEnt) { let old_pos = ent.posx[0].pos; let new_pos = ent.posx[1].pos; movehist_gen++; movehist_gen %= (movehist_len_max * 2); let meid = 'motionhint-marker-' + movehist_gen; let moved = ent.diff['Moved']; if (moved) { let d = moved.d; let ends = []; for (let end of [0,1]) { let s = (!end ? MOVEHIST_ENDS : d - MOVEHIST_ENDS) / d; ends.push([ (1-s) * old_pos[0] + s * new_pos[0], (1-s) * old_pos[1] + s * new_pos[1] ]); } let g = document.createElementNS(svg_ns,'g'); let sz = 4; let pi = players[ent.held]; let nick = pi ? pi.nick : ''; // todo: would be nice to place text variously along arrow, rotated let svg = ` ${nick} `; g.innerHTML = svg; space.insertBefore(g, movehist_end); movehist_revisible(); } } function movehist_revisible() { movehist_revisible_custmax(movehist_len_max); } function movehist_revisible_custmax(len_max: number) { let n = movehist_lens[movehist_len_i]; let i = 0; let node = movehist_end; while (i < len_max) { i++; // i now eg 1..10 node = node.previousElementSibling! as SVGGraphicsElement; if (node == movehist_start) return; let prop = i > n ? 0 : (n-i+1)/n; let stroke = (prop * 1.0).toString(); let marker = node.firstElementChild!; marker.setAttributeNS(null,'stroke-width',stroke); let line = marker.nextElementSibling!; line.setAttributeNS(null,'stroke-width',stroke); let text = line.nextElementSibling!; if (!prop) { text.setAttributeNS(null,'stroke','none'); text.setAttributeNS(null,'fill','none'); } else { text.setAttributeNS(null,'fill','yellow'); text.setAttributeNS(null,'stroke','orange'); } } for (;;) { let del = node.previousElementSibling!; if (del == movehist_start) return; del.remove(); } } // ----- logs ----- messages.Log = function (j: { when: string, logent: { html: string } }) { add_timestamped_log_message(j.when, j.logent.html); } function add_log_message(msg_html: string) { add_timestamped_log_message('', msg_html); } function add_timestamped_log_message(ts_html: string, msg_html: string) { var lastent = log_elem.lastElementChild; var in_scrollback = lastent == null || // inspired by // https://stackoverflow.com/questions/487073/how-to-check-if-element-is-visible-after-scrolling/21627295#21627295 // rejected // https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API (() => { let le_top = lastent.getBoundingClientRect()!.top; let le_bot = lastent.getBoundingClientRect()!.bottom; let ld_bot = logscroll_elem.getBoundingClientRect()!.bottom; console.log("ADD_LOG_MESSAGE bboxes: le t b, bb", le_top, le_bot, ld_bot); return 0.5 * (le_bot + le_top) > ld_bot; })(); console.log('ADD LOG MESSAGE ',in_scrollback, layout, msg_html); var ne : HTMLElement; function add_thing(elemname: string, cl: string, html: string) { var ie = document.createElement(elemname); ie.innerHTML = html; ie.setAttribute("class", cl); ne.appendChild(ie); } if (layout == 'Portrait') { ne = document.createElement('tr'); add_thing('td', 'logmsg', msg_html); add_thing('td', 'logts', ts_html); } else if (layout == 'Landscape') { ts_html = last_log_ts.update(ts_html); ne = document.createElement('div'); add_thing('span', 'logts', ts_html); ne.appendChild(document.createElement('br')); add_thing('span', 'logmsg', msg_html); ne.appendChild(document.createElement('br')); } else { throw 'bad layout ' + layout; } log_elem.appendChild(ne); if (!in_scrollback) { logscroll_elem.scrollTop = logscroll_elem.scrollHeight; } } // ----- zoom ----- function zoom_pct (): number | undefined { let str = zoom_val.value; let val = parseFloat(str); if (isNaN(val)) { return undefined; } else { return val; } } function zoom_enable() { zoom_btn.disabled = (zoom_pct() === undefined); } function zoom_activate() { let pct = zoom_pct(); if (pct !== undefined) { let fact = pct * 0.01; let last_ctm_a = space.getScreenCTM()!.a; (document.getElementsByTagName('body')[0] as HTMLElement) .style.transform = 'scale('+fact+','+fact+')'; if (fact != last_zoom_factor) { if (last_ctm_a == space.getScreenCTM()!.a) { console.log('FIREFOX GETSCREENCTM BUG'); firefox_bug_zoom_factor_compensation = fact; } else { console.log('No firefox getscreenctm bug'); firefox_bug_zoom_factor_compensation = 1.0; } last_zoom_factor = fact; } } zoom_btn.disabled = true; } // ----- test counter, startup ----- type TransmitUpdateEntry_Piece = { piece: PieceId, op: Object, }; type ErrorTransmitUpdateEntry_Piece = TransmitUpdateEntry_Piece & { cseq: ClientSeq | null, }; function handle_piece_update(j: TransmitUpdateEntry_Piece) { console.log('PIECE UPDATE ',j) var piece = j.piece; var m = j.op as { [k: string]: Object }; var k = Object.keys(m)[0]; let p = pieces[piece]; pieceops[k](piece,p, m[k]); }; messages.Piece = handle_piece_update; type PreparedPieceState = { pos: Pos, svg: string, desc: string, held: PlayerId | null, z: ZCoord, zg: Generation, pinned: boolean, angle: number, uos: UoDescription[], moveable: PieceMoveable, rotateable: boolean, multigrab: boolean, occregion: string | null, bbox: Rect, } pieceops.ModifyQuiet = function (piece: PieceId, p: PieceInfo, info: PreparedPieceState) { console.log('PIECE UPDATE MODIFY QUIET ',piece,info) piece_modify(piece, p, info); } pieceops.Modify = function (piece: PieceId, p: PieceInfo, info: PreparedPieceState) { console.log('PIECE UPDATE MODIFY LOuD ',piece,info) piece_note_moved(piece,p); piece_modify(piece, p, info); } pieceops.InsertQuiet = (insert_piece as any); pieceops.Insert = function (piece: PieceId, xp: any, info: PreparedPieceState) { let p = insert_piece(piece,xp,info); piece_note_moved(piece,p); } function insert_piece(piece: PieceId, xp: any, info: PreparedPieceState): PieceInfo { console.log('PIECE UPDATE INSERT ',piece,info) let delem = document.createElementNS(svg_ns,'defs'); delem.setAttributeNS(null,'id','defs'+piece); defs_marker.insertAdjacentElement('afterend', delem); let pelem = piece_element('piece',piece); let uelem = document.createElementNS(svg_ns,'use'); uelem.setAttributeNS(null,'id',"use"+piece); uelem.setAttributeNS(null,'href',"#piece"+piece); uelem.setAttributeNS(null,'data-piece',piece); let p = { uelem: uelem, delem: delem, } as any as PieceInfo; // fudge this, piece_modify_core will fix it pieces[piece] = p; p.queued_moves = 0; piece_modify(piece, p, info); return p; } pieceops.Delete = function (piece: PieceId, p: PieceInfo, info: {}) { console.log('PIECE UPDATE DELETE ', piece) piece_stop_special(piece, p); p.uelem.remove(); p.delem.remove(); delete pieces[piece]; if (p.held == us) { recompute_keybindings(); } let occregions_changed = occregion_update(piece, p, { occregion: null }); if (occregions_changed) redisplay_held_ancillaries(); } piece_error_handlers.PosOffTable = function() { return true ; } piece_error_handlers.Conflict = function() { return true ; } function piece_modify_image(piece: PieceId, p: PieceInfo, info: PreparedPieceImage) { p.delem.innerHTML = info.svg; p.pelem= piece_element('piece',piece)!; p.uos = info.uos; p.bbox = info.bbox; p.desc = info.desc; piece_resolve_special(piece, p); } function piece_resolve_special(piece: PieceId, p: PieceInfo) { let new_special = p.pelem.dataset.special ? JSON.parse(p.pelem.dataset.special) : null; let new_special_kind = new_special ? new_special.kind : ''; let old_special_kind = p .special ? p .special.kind : ''; if (new_special_kind != old_special_kind) { piece_stop_special(piece, p); } if (new_special) { console.log('SPECIAL START', new_special); new_special.stop = function() { }; p.special = new_special; special_renderings[new_special_kind](piece, p, new_special); } } function piece_stop_special(piece: PieceId, p: PieceInfo) { let s = p.special; p.special = null; if (s) { console.log('SPECIAL STOP', s); s.stop(piece, p, s); } } function piece_modify(piece: PieceId, p: PieceInfo, info: PreparedPieceState) { piece_modify_image(piece, p, info); piece_modify_core(piece, p, info); } function piece_set_pos_core(p: PieceInfo, x: number, y: number) { p.uelem.setAttributeNS(null, "x", x+""); p.uelem.setAttributeNS(null, "y", y+""); } function piece_modify_core(piece: PieceId, p: PieceInfo, info: PreparedPieceState) { p.uelem.setAttributeNS(null, "x", info.pos[0]+""); p.uelem.setAttributeNS(null, "y", info.pos[1]+""); p.held = info.held; p.held_us_raising = "NotYet"; p.pinned = info.pinned; p.moveable = info.moveable; p.rotateable = info.rotateable; p.multigrab = info.multigrab; p.angle = info.angle; p.bbox = info.bbox; piece_set_zlevel_from(piece,p,info); let occregions_changed = occregion_update(piece, p, info); piece_checkconflict_nrda(piece,p); redisplay_ancillaries(piece,p); if (occregions_changed) redisplay_held_ancillaries(); recompute_keybindings(); console.log('MODIFY DONE'); } function occregion_update(piece: PieceId, p: PieceInfo, info: { occregion: string | null } ) { let occregions_changed = ( info.occregion != null ? occregions.insert(piece, info.occregion) : occregions.remove(piece) ); return occregions_changed; } function redisplay_held_ancillaries() { for (let piece of Object.keys(pieces)) { let p = pieces[piece]; if (p.held != us) continue; redisplay_ancillaries(piece,p); } } type PreparedPieceImage = { svg: string, desc: string, uos: UoDescription[], bbox: Rect, } type TransmitUpdateEntry_Image = { piece: PieceId, im: PreparedPieceImage, }; messages.Image = function(j: TransmitUpdateEntry_Image) { console.log('IMAGE UPDATE ',j) var piece = j.piece; let p = pieces[piece]!; piece_modify_image(piece, p, j.im); redisplay_ancillaries(piece,p); recompute_keybindings(); console.log('IMAGE DONE'); } function piece_set_zlevel(piece: PieceId, p: PieceInfo, modify : (oldtop_piece: PieceId) => void) { // Calls modify, which should set .z and/or .gz, and/or // make any necessary API call. // // Then moves uelem to the right place in the DOM. This is done // by assuming that uelem ought to go at the end, so this is // O(new depth), which is right (since the UI for inserting // an object is itself O(new depth) UI operations to prepare. let oldtop_elem = (defs_marker.previousElementSibling! as unknown as SVGGraphicsElement); let oldtop_piece = oldtop_elem.dataset.piece!; modify(oldtop_piece); let ins_before = defs_marker let earlier_elem; for (; ; ins_before = earlier_elem) { earlier_elem = (ins_before.previousElementSibling! as unknown as SVGGraphicsElement); if (earlier_elem == pieces_marker) break; if (earlier_elem == p.uelem) continue; let earlier_p = pieces[earlier_elem.dataset.piece!]!; if (!piece_z_before(p, earlier_p)) break; } if (ins_before != p.uelem) space.insertBefore(p.uelem, ins_before); check_z_order(); } function check_z_order() { if (!otter_debug) return; let s = pieces_marker; let last_z = ""; for (;;) { s = s.nextElementSibling as SVGGraphicsElement; if (s == defs_marker) break; let piece = s.dataset.piece!; let z = pieces[piece].z; if (z < last_z) { json_report_error(['Z ORDER INCONSISTENCY!', piece, z, last_z]); } last_z = z; } } function piece_note_moved(piece: PieceId, p: PieceInfo) { let now = performance.now(); let need_redisplay = p.last_seen_moved == null; p.last_seen_moved = now; if (need_redisplay) redisplay_ancillaries(piece,p); let cutoff = now-1000.; while (movements.length > 0 && movements[0].this_motion < cutoff) { let mr = movements.shift()!; if (mr.p.last_seen_moved != null && mr.p.last_seen_moved < cutoff) { mr.p.last_seen_moved = null; redisplay_ancillaries(mr.piece,mr.p); } } movements.push({ piece: piece, p: p, this_motion: now }); } function piece_z_cmp(a: PieceInfo, b: PieceInfo) { if (a.z < b.z ) return -1; if (a.z > b.z ) return +1; if (a.zg < b.zg) return -1; if (a.zg > b.zg) return +1; return 0; } function piece_z_before(a: PieceInfo, b: PieceInfo) { return piece_z_cmp(a, b) < 0; } function pieceid_z_cmp(a: PieceId, b: PieceId) { return piece_z_cmp(pieces[a]!, pieces[b]!); } pieceops.Move = function (piece,p, info: Pos ) { piece_checkconflict_nrda(piece,p); piece_note_moved(piece, p); piece_set_pos_core(p, info[0], info[1]); } pieceops.MoveQuiet = function (piece,p, info: Pos ) { piece_checkconflict_nrda(piece,p); piece_set_pos_core(p, info[0], info[1]); } pieceops.SetZLevel = function (piece,p, info: { z: ZCoord, zg: Generation }) { piece_note_moved(piece,p); piece_set_zlevel_from(piece,p,info); } pieceops.SetZLevelQuiet = function (piece,p, info: { z: ZCoord, zg: Generation }) { piece_set_zlevel_from(piece,p,info); } function piece_set_zlevel_from(piece: PieceId, p: PieceInfo, info: { z: ZCoord, zg: Generation }) { piece_set_zlevel(piece,p, (oldtop_piece)=>{ p.z = info.z; p.zg = info.zg; }); } messages.Recorded = function (j: { piece: PieceId, cseq: ClientSeq, zg: Generation|null, svg: string | null, desc: string | null } ) { let piece = j.piece; let p = pieces[piece]!; piece_recorded_cseq(p, j); if (p.cseq_updatesvg != null && j.cseq >= p.cseq_updatesvg) { p.cseq_updatesvg = null; redisplay_ancillaries(piece,p); } if (j.svg != null) { p.delem.innerHTML = j.svg; p.pelem= piece_element('piece',piece)!; piece_resolve_special(piece, p); redisplay_ancillaries(piece,p); } if (j.zg != null) { var zg_new = j.zg; // type narrowing doesn't propagate :-/ piece_set_zlevel(piece,p, (oldtop_piece: PieceId)=>{ p.zg = zg_new; }); } if (j.desc != null) { p.desc = j.desc; } } function piece_recorded_cseq(p: PieceInfo, j: { cseq: ClientSeq }) { if (p.cseq_main != null && j.cseq >= p.cseq_main ) { p.cseq_main = null; } if (p.cseq_loose != null && j.cseq >= p.cseq_loose) { p.cseq_loose = null; } } messages.RecordedUnpredictable = function (j: { piece: PieceId, cseq: ClientSeq, ns: PreparedPieceState } ) { let piece = j.piece; let p = pieces[piece]!; piece_recorded_cseq(p, j); piece_modify(piece, p, j.ns); } messages.Error = function (m: any) { console.log('ERROR UPDATE ', m); var k = Object.keys(m)[0]; update_error_handlers[k](m[k]); } type PieceOpError = { error: string, error_msg: string, state: ErrorTransmitUpdateEntry_Piece, }; update_error_handlers.PieceOpError = function (m: PieceOpError) { let piece = m.state.piece; let cseq = m.state.cseq; let p = pieces[piece]; console.log('ERROR UPDATE PIECE ', piece, cseq, m, m.error_msg, p); if (p == null) return; // who can say! if (m.error != 'Conflict') { // Our gen was high enough we we sent this, that it ought to have // worked. Report it as a problem, then. add_log_message('Problem manipulating piece: ' + m.error_msg); // Mark aus as having no outstanding requests, and cancel any drag. piece_checkconflict_nrda(piece, p, true); } handle_piece_update(m.state); } function piece_checkconflict_nrda(piece: PieceId, p: PieceInfo, already_logged: boolean = false) { // Our state machine for cseq: // // When we send an update (api_piece_x) we always set cseq. If the // update is loose we also set cseq_beforeloose. Whenever we // clear cseq we clear cseq_beforeloose too. // // The result is that if cseq_beforeloose is non-null precisely if // the last op we sent was loose. // // We track separately the last loose, and the last non-loose, // outstanding API request. (We discard our idea of the last // loose request if we follow it with a non-loose one.) // // So // cseq_main > cseq_loose one loose request then some non-loose // cseq_main, no cseq_loose just non-loose requests // no cseq_main, but cseq_loose just one loose request // neither no outstanding requests // // If our only outstanding update is loose, we ignore a detected // conflict. We expect the server to send us a proper // (non-Conflict) error later. if (p.cseq_main != null || p.cseq_loose != null) { if (drag_pieces.some(function(dp) { return dp.piece == piece; })) { console.log('drag end due to conflict'); drag_end(); } } if (p.cseq_main != null) { if (!already_logged) add_log_message('Conflict! - simultaneous update'); } p.cseq_main = null; p.cseq_loose = null; } function test_swap_stack() { let old_bot = pieces_marker.nextElementSibling!; let container = old_bot.parentElement!; container.insertBefore(old_bot, defs_marker); window.setTimeout(test_swap_stack, 1000); } function startup() { console.log('STARTUP'); console.log(wasm_bindgen.setup("OK")); var body = document.getElementById("main-body")!; zoom_btn = document.getElementById("zoom-btn") as any; zoom_val = document.getElementById("zoom-val") as any; links_elem = document.getElementById("links") as any; ctoken = body.dataset.ctoken!; us = body.dataset.us!; gen = +body.dataset.gen!; let sse_url_prefix = body.dataset.sseUrlPrefix!; status_node = document.getElementById('status')!; status_node.innerHTML = 'js-done'; log_elem = document.getElementById("log")!; logscroll_elem = document.getElementById("logscroll") || log_elem; let dataload = JSON.parse(body.dataset.load!); held_surround_colour = dataload.held_surround_colour!; players = dataload.players!; delete body.dataset.load; uos_node = document.getElementById("uos")!; occregions = wasm_bindgen.empty_region_list(); space = svg_element('space')!; pieces_marker = svg_element("pieces_marker")!; defs_marker = svg_element("defs_marker")!; movehist_start = svg_element('movehist_marker')!; movehist_end = svg_element('movehist_end')!; rectsel_path = svg_element('rectsel_path')!; svg_ns = space.getAttribute('xmlns')!; for (let uelem = pieces_marker.nextElementSibling! as SVGGraphicsElement; uelem != defs_marker; uelem = uelem.nextElementSibling! as SVGGraphicsElement) { let piece = uelem.dataset.piece!; let p = JSON.parse(uelem.dataset.info!); p.uelem = uelem; p.delem = piece_element('defs',piece); p.pelem = piece_element('piece',piece); p.queued_moves = 0; occregion_update(piece, p, p); delete p.occregion; delete uelem.dataset.info; pieces[piece] = p; piece_resolve_special(piece,p); redisplay_ancillaries(piece,p); } if (test_update_hook == null) test_update_hook = function() { }; test_update_hook(); last_log_ts = wasm_bindgen.timestamp_abbreviator(dataload.last_log_ts); for (let ent of dataload.movehist.hist) { movehist_record(ent); } var es = new EventSource( sse_url_prefix + "/_/updates?ctoken="+ctoken+'&gen='+gen ); es.onmessage = function(event) { console.log('GOTEVE', event.data); var k; var m; try { var [tgen, ms] = JSON.parse(event.data); for (m of ms) { k = Object.keys(m)[0]; messages[k](m[k]); } gen = tgen; test_update_hook(); } catch (exc) { var s = exc.toString(); string_report_error('exception handling update ' + k + ': ' + JSON.stringify(m) + ': ' + s); } } es.addEventListener('commsworking', function(event) { console.log('GOTDATA', (event as any).data); status_node.innerHTML = (event as any).data; }); es.addEventListener('player-gone', function(event) { console.log('PLAYER-GONE', event); status_node.innerHTML = (event as any).data; add_log_message('You are no longer in the game'); space.removeEventListener('mousedown', some_mousedown); document.removeEventListener('keydown', some_keydown); es.close(); }); es.addEventListener('updates-expired', function(event) { console.log('UPDATES-EXPIRED', event); string_report_error('connection to server interrupted too long'); }); es.onerror = function(e) { let info = { updates_error : e, updates_event_source : es, updates_event_source_ready : es.readyState, update_oe : (e as any).className, }; if (es.readyState == 2) { json_report_error({ reason: "TOTAL SSE FAILURE", info: info, }) } else { console.log('SSE error event', info); } } recompute_keybindings(); space.addEventListener('mousedown', some_mousedown); space.addEventListener('dragstart', function (e) { e.preventDefault(); e.stopPropagation(); }, true); document.addEventListener('keydown', some_keydown); check_z_order(); } type DieSpecialRendering = SpecialRendering & { cd_path: SVGPathElement, loaded_ts: DOMHighResTimeStamp, loaded_remprop: number, total_ms: number, radius: number, anim_id: number | null, }; special_renderings['Die'] = function(piece: PieceId, p: PieceInfo, s: DieSpecialRendering) { let cd_path = document.getElementById('def.'+piece+'.die.cd'); if (!cd_path) return; s.cd_path = cd_path as any as SVGPathElement; s.loaded_ts = performance.now(); s.loaded_remprop = parseFloat(cd_path.dataset.remprop!)!; s.total_ms = parseFloat(cd_path.dataset.total_ms!)!; s.radius = parseFloat(cd_path.dataset.radius!)!; s.stop = die_rendering_stop as any; die_request_animation(piece, p, s); } as any; function die_request_animation(piece: PieceId, p: PieceInfo, s: DieSpecialRendering) { s.anim_id = window.requestAnimationFrame( function(ts) { die_render_frame(piece, p, s, ts) } ); } function die_render_frame(piece: PieceId, p: PieceInfo, s: DieSpecialRendering, ts: DOMHighResTimeStamp) { s.anim_id = null; let remprop = s.loaded_remprop - (ts - s.loaded_ts) / s.total_ms; //console.log('DIE RENDER', piece, s, remprop); if (remprop <= 0) { console.log('DIE COMPLETE', piece, s, remprop); let to_remove: Element = s.cd_path; for (;;) { let previous = to_remove.previousElementSibling!; // see dice/overlya-template-extractor if (to_remove.tagName == 'text') break; to_remove.remove(); to_remove = previous; } } else { let path_d = wasm_bindgen.die_cooldown_path(s.radius, remprop); s.cd_path.setAttributeNS(null, "d", path_d); die_request_animation(piece, p, s); } } function die_rendering_stop(piece: PieceId, p: PieceInfo, s: DieSpecialRendering) { let anim_id = s.anim_id; if (anim_id == null) return; s.anim_id = null; window.cancelAnimationFrame(anim_id); } declare var wasm_input : any; var wasm_promise : Promise;; function doload(){ console.log('DOLOAD'); globalinfo_elem = document.getElementById('global-info')!; layout = globalinfo_elem!.dataset!.layout! as any; var elem = document.getElementById('loading_token')!; var ptoken = elem.dataset.ptoken; xhr_post_then('/_/session/' + layout, JSON.stringify({ ptoken : ptoken }), loaded); wasm_promise = wasm_input .then(wasm_bindgen); } function loaded(xhr: XMLHttpRequest){ console.log('LOADED'); var body = document.getElementById('loading_body')!; wasm_promise.then((got_wasm) => { wasm = got_wasm; body.outerHTML = xhr.response; try { startup(); } catch (exc) { let s = exc.toString(); string_report_error_raw('Exception on load, unrecoverable: ' + s); } }); } // todo scroll of log messages to bottom did not always work somehow // think I have fixed this with approximation //@@notest doload();