Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/Engine/WorldModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1186,7 +1186,17 @@ internal void SignalTurnSuspended(bool scroll = true)
{
if (scroll && Version >= WorldModelVersion.v540)
{
// ScrollToEnd() must run first: it targets wherever MarkScrollPosition()
// was last called (the *start* of this turn's own output, set either by
// this method after the previous turn or by player.js/beginWait() for an
// interactive command/mid-turn wait). Only once that's queued do we
// record where this turn's output ends, ready as the target for
// whichever turn comes next — interactive command, walkthrough step,
// timer, or wait/ask/menu resumption, since they all funnel through this
// one hook (unlike the player.js/beginWait() JS-side calls, which only
// cover interactive typing and mid-turn waits).
ScrollToEnd();
MarkScrollPosition();
}
// Every wait()/get input()/ask/show menu call site pairs BeginPendingCallback/
// EndPendingCallbackAsync 1:1 with a SignalTurnSuspended call, and the two
Expand Down Expand Up @@ -1585,6 +1595,11 @@ private void ScrollToEnd()
_ = PlayerUi.RunScriptAsync("scrollToEnd", null);
}

private void MarkScrollPosition()
{
_ = PlayerUi.RunScriptAsync("markScrollPosition", null);
}

internal void LogException(Exception ex)
{
LogError?.Invoke(ex);
Expand Down
13 changes: 13 additions & 0 deletions src/PlayerCore/Resources/playercore.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@
font-family: Arial, Helvetica, sans-serif;
overflow-y: scroll;
background-attachment: fixed;
/* playercore.js explicitly recomputes and sets scroll position itself on
every relevant DOM change (new turn, picture frame resizing — see
scrollToTurnStart()/setPanelHeight() in playercore.js), including
content inserted above the current scroll position (the sticky
picture frame growing once its image loads). Chrome's default scroll
anchoring tries to do the same job automatically to avoid a visual
jump, but its guess and playercore.js's own (more informed) target
can disagree — and since anchoring applies *before* playercore.js's
own correction runs, its adjustment becomes the new "current scroll
position" that scrollToTurnStart()'s never-scroll-backward guard then
treats as intentional, silently defeating the real correction.
Disabling it leaves scroll positioning entirely to our own logic. */
overflow-anchor: none;
}

html,
Expand Down
165 changes: 156 additions & 9 deletions src/PlayerCore/Resources/playercore.js
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,22 @@ function initPlayerUI() {
}
};

// Fires from the picture frame's <img onload> (SetFramePicture/
// JS.setPanelContents) once the image has actually finished loading and
// #gamePanel has its real, final height — before that, stickyOverlayHeight()
// only sees whatever height an unloaded <img> renders at (effectively 0),
// so the turn's own scrollToEnd() call (fired the moment its text and the
// <img> tag are added, well before the image itself has loaded) can
// under-count the frame and land the turn's opening line where the
// picture is about to appear. Re-targets scrollToTurnStart() at
// lastScrollTurnStart — a snapshot of whichever turn start that original
// call used — rather than re-reading beginningOfCurrentTurnScrollPosition,
// which markScrollPosition() has typically already advanced to the next
// turn's start by the time a real image finishes loading.
window.setPanelHeight = function () {
if (_showGrid) return;
setTimeout(function () {
scrollToEnd();
scrollToTurnStart(lastScrollTurnStart);
}, 100);
};

Expand Down Expand Up @@ -495,13 +507,132 @@ function isElementVisible(element) {

var _animateScroll = true;

function scrollToEnd() {
if (!_animateScroll) {
$('html,body').scrollTop(document.body.scrollHeight);
} else {
$('html,body').animate({scrollTop: document.body.scrollHeight}, 'fast');
// The page's own scrollable content sits behind #qv-status (fixed) and
// whichever of #gamePanel/#gridPanel (sticky — the static picture frame
// feature, e.g. "The Shack") is showing a picture. Once scrolled past their
// natural flow position they float on top of whatever's beneath them, so a
// scroll target that ignores them can land content behind the frame instead
// of below it. #gamePanel/#gridPanel's own "top" CSS already accounts for
// #qv-status's height (see updateStatusVisibility()), so taking the deepest
// bottom edge of the three — rather than summing them — avoids double
// counting #qv-status when a picture is also showing.
function stickyOverlayHeight() {
var bottom = 0;
["#qv-status", "#gamePanel", "#gridPanel"].forEach(function (selector) {
var $el = $(selector);
if ($el.length && isElementVisible($el)) {
bottom = Math.max(bottom, (parseFloat($el.css("top")) || 0) + $el.outerHeight());
}
});
return bottom;
}

// Snapshot of whichever turn-start position the most recent scrollToEnd()
// call actually used, kept separately from beginningOfCurrentTurnScrollPosition
// — see setPanelHeight()'s doc comment (above, in initPlayerUI()) for why.
var lastScrollTurnStart = 0;

// Scrolls just far enough that turnStart (the top of some turn's output)
// lands below the fixed/sticky chrome at the top of the viewport, rather than
// jumping straight to the bottom of the document — otherwise a long turn's
// opening lines scroll off the top (or behind the status bar/picture frame)
// before the player can read them. Never scrolls backward past wherever the
// player currently is (e.g. if they've scrolled up to reread), and never
// past the true bottom of the document.
//
// A turn can emit several OutputText calls, each triggering scrollToEnd(),
// and — for a walkthrough or any other rapid burst of turns — the next one
// can easily land while the previous call's animation is still running.
// easeInOutCubic decelerates to a near-stop at the end of every animation;
// restarting that curve from scratch on each interruption made the scroll
// visibly stutter (slow down, barely move, speed up, slow down again) rather
// than glide, with the final leg then covering whatever distance was left in
// one comparatively large jump once nothing interrupted it further. Only the
// very first call in a burst (starting from a stationary page) gets the
// eased curve; anything that interrupts an already-running scroll continues
// at the same steady rate instead of re-decelerating and re-accelerating.
//
// allowBackward (default false) lets the target move the scroll position
// *up*, not just down — normally refused so that a manual scroll-up to
// reread history doesn't get yanked back down by an unrelated later turn.
// clearScreen() passes true: a turn that clears the screen can still hit an
// ordinary turn-boundary scrollToEnd() call using the *pre-clear* turn start
// (SignalTurnSuspended's own call, queued before the script that called
// ClearScreen has necessarily finished, can still be based on wherever the
// old, now-discarded page happened to end) — clamped against the old,
// now-irrelevant document height, that can scroll to a wrong, much-too-far
// position before clearScreen's own correction below runs. Since the entire
// point of a clear is "nothing before this matters any more", its own
// correction must be allowed to override that, forward or backward.
function scrollToTurnStart(turnStart, allowBackward) {
lastScrollTurnStart = turnStart;

// A picture frame image that hasn't finished loading yet (successfully
// or not) doesn't have its real size yet — #gamePanel/#gridPanel's
// height right now is whatever an unloaded <img> renders at
// (effectively 0), so any target computed against it would undercount
// the frame and visibly place content where the picture is about to
// appear, needing a jump to fix once it loads. Skip for now instead:
// setPanelContents() wires up a load/error listener on every panel
// image (on top of SetFramePicture's own <img onload>) that calls back
// into here once the frame's real height is known, so this is never
// left stuck — just deferred until the answer is actually right.
var pendingImg = $("#gamePanel:visible img, #gridPanel:visible img").filter(function () {
return !this.complete;
})[0];
if (pendingImg) {
return;
}
$("#txtCommand").focus();

if (!_animateScroll) {
// Used while fast-forwarding a walkthrough — always jump straight to
// the true end rather than following the turn-by-turn target below.
$('html,body').stop(true, false).scrollTop(document.body.scrollHeight);
focusCommandInput();
return;
}

var headerHeight = stickyOverlayHeight();
// turnStart is measured relative to #gameContent's own top (its height()
// when markScrollPosition() ran), not the page's — #gameContent isn't
// necessarily at page-top itself, since #gamePanel/#gridPanel (sticky,
// but still flow-participating) reserve real layout space above it
// whenever a picture is showing. Skipping this offset used to cancel out
// by accident whenever the frame was already showing before the turn
// started (its contribution to both this offset and headerHeight above
// were equal), which is why it went unnoticed until the frame's size
// changed *after* the turn had already been positioned.
var pageTurnStart = $("#gameContent").offset().top + turnStart;
var maxScrollTop = Math.max($(document).height() - $(window).height(), 0);
var target = Math.min(Math.max(pageTurnStart - headerHeight - 20, 0), maxScrollTop);
var currentScrollTop = Math.max($("body").scrollTop(), $("html").scrollTop());

if (target !== currentScrollTop && (allowBackward || target > currentScrollTop)) {
var wasAlreadyScrolling = $('html,body').is(":animated");
var distance = Math.abs(target - currentScrollTop);
var duration = Math.min(distance / 0.4, 2000);
var easing = wasAlreadyScrolling ? "linear" : "easeInOutCubic";
$('html,body').stop(true, false).animate({scrollTop: target}, duration, easing);
}
focusCommandInput();
}

// Plain jQuery .focus() calls the native HTMLElement.focus(), which — for an
// element not already focused — asks the browser to scroll it into view.
// #txtCommand sits right after all of #divOutput, so whenever it isn't
// already focused (page just loaded, on mobile before the on-screen keyboard
// has been tapped, focus lost to a click/selection elsewhere) that silently
// jumped straight to the document's bottom, undoing every calculation above.
// {preventScroll: true} keeps the input usable (typing still works
// immediately) without fighting the scroll position we just computed.
function focusCommandInput() {
document.getElementById("txtCommand")?.focus({preventScroll: true});
}

// beginningOfCurrentTurnScrollPosition is set by markScrollPosition() before
// the turn began (see its own doc comment).
function scrollToEnd() {
scrollToTurnStart(beginningOfCurrentTurnScrollPosition);
}

function SetAnimateScroll(value) {
Expand Down Expand Up @@ -538,6 +669,12 @@ function setPanelContents(html) {
$("#gamePanel").hide()
}
$("#gamePanel").html(html);
// Belt-and-braces alongside SetFramePicture's own <img onload="setPanelHeight()">
// markup: guarantees a retry once any image here finishes loading (success
// or failure) even for panel HTML set directly via JS.setPanelContents that
// doesn't happen to include that onload itself — scrollToTurnStart() below
// relies on one of these eventually firing to know it's safe to scroll.
$("#gamePanel img").one("load error", setPanelHeight);
setPanelHeight();
}

Expand Down Expand Up @@ -920,8 +1057,18 @@ function clearScreen() {
$("#divOutput").html("");
createNewDiv("left");
beginningOfCurrentTurnScrollPosition = 0;
// scrollToTurnStart(0), not a bare scrollTop(0) — the new page's
// content starts at #gameContent height 0, but #gameContent itself
// isn't necessarily at the very top of the *page*: a static picture
// frame (#gamePanel/#gridPanel, e.g. "The Shack") reserves real flow
// space above it, and jumping to page-scrollTop 0 only clears that
// frame if the frame is shorter than the viewport — on a small/mobile
// screen it very often isn't, leaving the new page's opening line
// rendered behind the frame. This fires on every gamebook page
// transition (DoPage → ClearScreen when game.clearlastpage), which
// includes game start (the player's first changedparent).
setTimeout(function () {
$("html,body").scrollTop(0);
scrollToTurnStart(0, true);
}, 100);
} else {
$("#divOutput").append("<hr class='clearedAbove' />");
Expand All @@ -935,7 +1082,7 @@ function clearScreen() {
createNewDiv('left');
beginningOfCurrentTurnScrollPosition = 0;
setTimeout(function () {
$('html,body').scrollTop(0);
scrollToTurnStart(0, true);
}, 100);
}
$("#outputData").appendTo($("#divOutput"));
Expand Down
67 changes: 67 additions & 0 deletions tests/e2e/fixtures/scroll-clearscreen-after-wait-test.aslx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<asl version="580">
<include ref="English.aslx" />
<include ref="Core.aslx" />
<game name="ScrollClearScreenStaleTurnStartTest">
</game>
<object name="room">
<object name="player">
</object>
</object>
<command>
<pattern>gopage1</pattern>
<script><![CDATA[
msg("<span id='page1mark'></span>Page 1 begins here.")
msg("Page 1, filler line 1 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 2 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 3 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 4 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 5 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 6 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 7 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 8 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 9 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 10 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 11 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 12 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 13 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 14 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 15 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 16 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 17 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 18 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 19 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 20 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 21 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 22 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 23 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 24 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 25 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 26 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 27 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 28 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 29 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 30 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 31 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 32 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 33 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 34 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 35 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 36 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 37 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 38 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 39 of long output to make this page well over one screenful before the wait.")
msg("Page 1, filler line 40 of long output to make this page well over one screenful before the wait.")
wait ()
ClearScreen
msg("<span id='page2mark'></span>Page 2 begins here - a short page.")
msg("Just a little bit of text on this page.")
]]></script>
</command>
<walkthrough name="clearafterwait">
<steps>
<![CDATA[
gopage1
]]>
</steps>
</walkthrough>
</asl>
32 changes: 32 additions & 0 deletions tests/e2e/fixtures/scroll-clearscreen-frame-test.aslx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<asl version="580">
<include ref="English.aslx" />
<include ref="Core.aslx" />
<game name="ScrollClearScreenFrameTest">
</game>
<object name="room">
<object name="player">
</object>
</object>
<command>
<pattern>showframe</pattern>
<script><![CDATA[
JS.setPanelContents ("<div id='testframe' style='height:200px;background:red;'>FRAME</div>")
]]></script>
</command>
<command>
<pattern>gotopage2</pattern>
<script><![CDATA[
ClearScreen
msg("<span id='page2mark'></span>Page 2 begins here.")
msg("Page 2, some more text right after the marker.")
]]></script>
</command>
<walkthrough name="clearwithframe">
<steps>
<![CDATA[
showframe
gotopage2
]]>
</steps>
</walkthrough>
</asl>
Loading
Loading