Commit c8b45fe2 authored by Leon Arnott's avatar Leon Arnott
Browse files

Added a (meter:) command macro, used for creating a videogame bar-graph meter...

Added a (meter:) command macro, used for creating a videogame bar-graph meter that's bound to a numeric variable.

I'm rather satisfied with this implementation of meters. The only concern right now is that it's the only "interface" macro in the documentation.

Also fixed a bug with (enchant:) where errors produced by the given lambda wouldn't be reported.
parent 98e0788c9907
......@@ -191,6 +191,7 @@ Documentation is at http://twine2.neocities.org/. See below for compilation inst
* Added an `(input-box:)` macro, which places a `<textarea>` element in the passage, sized using the same values given to the `(box:)` macro, and optionally bound to a variable.
* Added a `(force-input-box:)` macro, designed for linear narratives, that creates what seems to be a normal `(input-box:)`, but which, when typed into, instantly replaces the entered text with portions of a predefined string.
* Added a `(checkbox:)` macro, which creates a labeled `<input type=checkbox>` element in the passage, which is bound to a boolean variable. You'll usually want to use a `2bind` binding with this macro.
* Added a `(meter:)` macro, used for creating a videogame bar-graph meter that's bound to a numeric variable. You can provide this with a colour or gradient for the bar, and a text label, as well as make the bar left-aligned, right-aligned or centered.
* Added a `(link-rerun:)` macro, which is similar to `(link-repeat:)`, but which replaces the hook on each click rather than appending to it.
* Added a `(rerun:)` macro, which replaces a given hook with its original source, eliminating any changes made to it by `(replace:)` or other macros. This also runs any macros inside the hook an additional time.
* Added a `(hide:)` macro, which removes the contents of a given hook from the passage, but allows the `(show:)` macro to restore the contents later. Hooks hidden with `(hide:)` will not re-run any containing macros when `(show:)` is used on them later.
......
......@@ -77,6 +77,13 @@ define(['utils/operationutils'], ({toSource}) => {
return Object.assign(Object.create(this), { angle, stops: stops.sort((a,b) => a.stop - b.stop) });
},
/*
This creates a copy of the gradient, but multiplies the colour stops by the given number.
*/
multiply(mul) {
return Gradient.create(this.angle, this.stops.map(({colour, stop}) => ({ colour, stop: stop*mul })));
},
/*
This converts the colour into a CSS linear-gradient() function.
*/
......
......@@ -67,7 +67,11 @@ define(['jquery', 'utils', 'internaltypes/changedescriptor', 'datatypes/changerc
let changer;
if (lambda) {
changer = lambda.apply(section, { loop: scope.TwineScript_GetProperty(i), pos: i+1 });
if (!ChangerCommand.isPrototypeOf(changer)) {
if (TwineError.containsError(changer)) {
e.replaceWith(changer.render(''));
lambda = changer = null;
}
else if (!ChangerCommand.isPrototypeOf(changer)) {
e.replaceWith(TwineError.create("macrocall",
"The 'via' lambda given to (enchant:) must return a changer, not " + objectName(changer) + "."
).render(""));
......
"use strict";
define(['jquery', 'macros', 'utils', 'state', 'passages', 'renderer', 'engine', 'internaltypes/twineerror',
'internaltypes/twinenotifier', 'datatypes/assignmentrequest', 'datatypes/hookset', 'datatypes/codehook', 'datatypes/lambda', 'internaltypes/varref', 'datatypes/typedvar', 'datatypes/varbind', 'utils/operationutils', 'utils/renderutils'],
($, Macros, Utils, State, Passages, Renderer, Engine, TwineError, TwineNotifier, AssignmentRequest, HookSet, CodeHook, Lambda, VarRef, TypedVar, VarBind, {printBuiltinValue, objectName}, {dialog, geomParse, geomStringRegExp}) => {
'internaltypes/twinenotifier', 'datatypes/assignmentrequest', 'datatypes/hookset', 'datatypes/codehook', 'datatypes/lambda', 'datatypes/colour', 'datatypes/gradient', 'internaltypes/varref', 'datatypes/typedvar', 'datatypes/varbind', 'utils/operationutils', 'utils/renderutils'],
($, Macros, Utils, State, Passages, Renderer, Engine, TwineError, TwineNotifier, AssignmentRequest, HookSet, CodeHook, Lambda, Colour, Gradient, VarRef, TypedVar, VarBind, {printBuiltinValue, objectName}, {dialog, geomParse, geomStringRegExp}) => {
/*d:
Command data
......@@ -20,9 +20,9 @@ define(['jquery', 'macros', 'utils', 'state', 'passages', 'renderer', 'engine',
by applying (t8n-depart:) and (t8n-arrive:). (Note that since normal passage links are identical to the
(link-goto:) macro, you can also attach changers to passage links.)
*/
const {Any, Everything, rest, either, optional, zeroOrMore, positiveInteger} = Macros.TypeSignature;
const {Any, Everything, rest, either, optional, zeroOrMore, positiveInteger, positiveNumber} = Macros.TypeSignature;
const {assign} = Object;
const {floor,ceil,abs} = Math;
const {floor,ceil,abs,max,min} = Math;
const {noop} = $;
/*
......@@ -936,6 +936,9 @@ define(['jquery', 'macros', 'utils', 'state', 'passages', 'renderer', 'engine',
If, when the element is created, the bound variable is not a number, then an error will result.
However, if the bound variable ever changes to a non-number data value after that, then the counter will simply not update, instead of producing an error.
See also:
(meter:)
Added in: 3.2.0
#sidebar 6
*/
......@@ -958,7 +961,7 @@ define(['jquery', 'macros', 'utils', 'state', 'passages', 'renderer', 'engine',
/*
This standard twoWayBindEvent updates the counter's text, very simply.
*/
cd.data.twoWayBindEvent = (expr, obj, name) => {
cd.data.twoWayBindEvent = (_, obj, name) => {
if (bind.varRef.matches(obj,name)) {
const value = bind.varRef.get();
if(typeof value === "number") {
......@@ -989,6 +992,195 @@ define(['jquery', 'macros', 'utils', 'state', 'passages', 'renderer', 'engine',
},
[VarBind, String, optional(String)]);
/*d:
(meter: Bind, Number, String, [String], [Colour or Gradient]) -> Command
A command that creates a horizontal bar-graph meter, showing the current value of a number variable, relative to a maximum value, and updating it whenever
that variable changes.
Example usage:
* `(set:$batteryPower to 800)(meter: bind $batteryPower, 1000, "X", "Battery Power", (gradient: 90, 0, red, 1, orange))` creates a centered meter showing the value of the $batteryPower variable,
from 0 to 1000, using a gradient from orange (full) to red (empty).
* `(set:$threatLevel to 2)(b4r:'solid')(meter: bind $threatLevel, 10, "==X", red)` creates a right-aligned meter showing the value of the $threatLevel variable,
from 0 to 10, in red, with a solid border.
Rationale:
For those making number-heavy games, presenting those numbers in an immediately recognisable fashion can be essential to a smooth game experience - and there are times when simply
stating the numbers in the prose isn't as direct as you'd like. The standard videogame UI meter, a bar that fills with a colour to represent an important value, is a
visual idiom familiar to many people. In addition to their familiarity, meters have important semantic value, too - simply by graphically presenting a value in a meter,
a player can immediately get a sense of how important to their play session that value is, as well as understand what numeric range that value should occupy during play.
Details:
The meter will graph the value of the bound variable, from 0 to the given maximum value number (which must be positive). For instance, if that number is 20, then if the bound variable is 5,
the meter bar will be 25% full.
The meter is a "box" element, which takes up the full width of the passage or hook in which it's contained. Placing it inside column markup can
allow you to place text alongside it, or other (meter:) commands, if you so desire.
The first string you give to this macro is a "sizing line" identical to that accepted by (box:) and (input-box:) - consult their documentation for more
information about those lines. However, the sizing line also determines the direction that the meter's bar fills. If the meter is left-aligned or
occupies the full width (by being given "X" as a sizing string), the bar fills from left (empty) to right (full), and the opposite is true for
right-alignment. Centre-alignment causes the bar to fill from the centre, expanding outward in both directions.
The second, optional string is a label that is placed inside the meter, on top of the bar. This text is aligned in the same direction as the meter itself.
Either a colour or a gradient can be given as the final value, with which to colour the bar. If neither is given, the bar will be a simple gray.
If a gradient is given, its rotation will be automatically changed to 90 degrees, with the leftmost colour (at colour stop 0) being placed at
the "empty" end of the meter, and the rightmost colour (at colour stop 1) placed at the "full" end. If the bar is center-aligned, then
the gradient will be modified, with both ends of the graph having the leftmost colour, and the center having the rightmost colour.
The meter is exclusively horizontal, and cannot be rotated unless you attach (text-rotate:) to it.
Note: In Internet Explorer 10, the vertical height of the meter may be lower than as drawn in other browsers. This is due to a CSS limitation in that browser.
See also:
(icon-counter:)
Added in: 3.2.0.
#interface
*/
Macros.addCommand('meter',
(_, __, widthStr, labelOrGradient) => {
if (typeof labelOrGradient === 'string' && !labelOrGradient.trim()) {
return TwineError.create("datatype", `The label string given to (meter:) can't be empty or only whitespace.`);
}
if (widthStr.search(geomStringRegExp) === -1
/*
A rather uncomfortable check needs to be made here: because widthStrs can have zero "=" signs
on either side, and a middle portion consisting of essentially anything, the default text box
could be confused for it, unless all 100%-width strings are prohibited to just single characters.
*/
|| (!widthStr.includes("=") && widthStr.length > 1)) {
return TwineError.create("datatype", 'The (meter:) macro requires a sizing line'
+ '("==X==", "==X", "=XXXX=" etc.) be provided, not ' + JSON.stringify(widthStr) + '.');
}
},
(cd, section, bind, maxValue, widthStr, labelOrGradient, gradient) => {
/*
If a label wasn't provided, but a gradient was, correct the arguments.
*/
if (labelOrGradient && typeof labelOrGradient !== 'string') {
gradient = labelOrGradient;
labelOrGradient = undefined;
}
/*
The default colour is a single 50% transparent gray, which is agnostic of whether the passage's colour
scheme is dark or light.
*/
if (!gradient) {
gradient = Colour.create({h:0, s:0, l:0.5, a:0.5});
}
/*
Single-colours can be provided instead of gradients. However, the bar needs to be drawn with a gradient background,
so that background-size can affect it. So, convert it to a gradient.
*/
if (Colour.isPrototypeOf(gradient)) {
gradient = Gradient.create(90, [{ colour: gradient, stop:0, }, { colour: gradient, stop:1, }]);
}
/*
The sizing line is used to determine the bar and text's alignment, as well as that of the <tw-expression> itself.
Center alignment is used if there's left and right margins, and right alignment is used if there's no left margin.
Notice that the "X" sizing line is interpreted as left alignment by default, rather than center alignment.
*/
const {marginLeft,size} = geomParse(widthStr);
const isCenter = marginLeft > 0 && Math.ceil(marginLeft + size) < 100;
/*
This function is used for both initial assigment of styles to the <tw-meter>, and to live updates.
Only the styles necessary for drawing the meter bar, in and of itself, are attached to the <tw-meter> element -
the borders, placement and sizing are left to the parent <tw-expression>, so that borders applied by
(b4r:) (which are placed on the <tw-expression>) interact correctly with the meter.
Hence, this receives "height:100%".
*/
const makeStyleString = value => {
const clampedSize = max(0, min(1, value / maxValue));
const g = gradient.multiply(maxValue / value);
return `height:100%;background-image:${
/*
A center-aligned graph consists of two different gradient backgrounds, extending to the left and right
from the centre. This line produces the left extension.
*/
(isCenter ? assign(g, { angle: 270 }).toLinearGradientString() + " no-repeat, " : '')
/*
And, this line produces either the right extension, or the entire bar for other alignments.
Note that the gradient angle is reversed for right-alignment (270 rather than 90).
*/
+ assign(g, { angle: isCenter || marginLeft === 0 ? 90 : 270 }).toLinearGradientString()
} no-repeat;background-size:${
isCenter ? Array(2).fill(clampedSize * 50 + "%") : clampedSize * 100 + "%"
};background-position-x:${
/*
This slightly complicated forumla is required to eliminate the compensatory
position provided by background-position-x's normal behaviour, where 0 places
the left edge at the left side and 100 places the right edge at the right side.
Thanks to this page for assistance:
https://css-tricks.com/focusing-background-image-precise-location-percentages/
*/
isCenter ? (-50 * 2/(2-clampedSize) + 100) + "%," + (50 * 2/(2-clampedSize)) + "%"
: marginLeft === 0 ? 'left' : 'right'
};text-align:${
isCenter ? 'center' : marginLeft === 0 ? 'left' : 'right'
}`;
};
/*
These other styles related to placement within the passage are, as mentioned, attached to the <tw-expression>
using the ChangeDescriptor's styles array.
*/
cd.styles.push({
'margin-left': marginLeft + '%',
width: size + '%',
height: '1.5em',
display: 'block',
});
/*
Being a UI display macro, all binds given to this macro are automatically promoted to 2binds, because the second bind
(variable -> element) is the only one that matters.
*/
cd.attr.push({"data-2bind": true});
/*
As this is a deferred rendering macro (if a label is present), the current tempVariables
object must be stored for reuse, as the section pops it when normal rendering finishes.
*/
const tempVariables = labelOrGradient && section.stackTop.tempVariables;
/*
This standard twoWayBindEvent updates the meter's CSS, very simply.
*/
cd.data.twoWayBindEvent = (expr, obj, name) => {
if (bind.varRef.matches(obj,name)) {
const value = bind.varRef.get();
if (typeof value === "number") {
const icon = cd.target.children('tw-meter');
icon.attr('style', makeStyleString(value > 0 ? floor(value) : ceil(value)));
if (labelOrGradient) {
/*
Re-render the label, in the same manner as (cycling-link:).
*/
const cd2 = assign({}, cd, { source: labelOrGradient, target: icon, append: 'replace', transitionDeferred: false, });
cd.section.renderInto("", null, cd2, tempVariables);
}
}
}
};
let value = bind.varRef.get();
/*
This check should have the same logic as the (trunc:) macro.
*/
value = value > 0 ? floor(value) : ceil(value);
/*
Though it's possible for it to be redefined, a type restriction check might as well be performed now
when the command is being created.
*/
if (typeof value !== "number") {
return TwineError.create("datatype",
`(meter:) can only be bound to a variable holding a number, not ${objectName(value)}.`
);
}
/*
Like (box:), this needs display:block so that it can take up an entire row.
*/
return assign(cd, { source: `<tw-meter style="${ makeStyleString(value) }">${labelOrGradient || ''}</tw-meter>` });
},
[VarBind, positiveNumber, String, optional(either(String, Colour, Gradient)), optional(either(Colour, Gradient))]);
/*d:
(cycling-link: [Bind], ...String) -> Command
......
......@@ -476,7 +476,7 @@ define(['jquery', 'utils/naturalsort', 'utils', 'utils/operationutils', 'datatyp
Some final commonly-used TypeSignatures.
*/
Object.assign(Macros.TypeSignature, {
positiveNumber: Macros.TypeSignature.numberRange(0.0001, Infinity),
positiveNumber: Macros.TypeSignature.numberRange(Math.pow(2,-52), Infinity),
nonNegativeNumber: Macros.TypeSignature.numberRange(0, Infinity),
percent: Macros.TypeSignature.numberRange(0, 1),
});
......
......@@ -118,7 +118,7 @@ const outputFile = {
"twineLink:not(.text)": typeColours.twineLink,
tag:
"color: hsla(240,34%,46%,1.0);",
"color: hsla(240,34%,25%,1.0);",
boolean: typeColours.boolean,
string: typeColours.string,
......
......@@ -174,6 +174,13 @@ tw-icon {
}
}
/*
A <tw-meter> is a special element produced by (meter:).
*/
tw-meter {
display:block;
}
/*** Inner passage formatting ***/
/*
......
......@@ -568,4 +568,85 @@ describe("interface macros", function(){
expect(p.find('input:checked').length).toBe(0);
});
});
describe("the (meter:) macro", function() {
it("accepts a bound number variable, a positive number, a sizing string, an optional string label, and an optional colour or gradient", function() {
expect("(meter:)").markupToError();
expect("(meter: bind $foo)").markupToError();
expect("(meter: bind $foo, 10)").markupToError();
expect("(meter: bind $foo, '=X')").markupToError();
expect("(meter: bind $foo, 10, '=X')").not.markupToError();
expect("(meter: bind $foo, 10, '=X', 'Yo')").not.markupToError();
expect("(meter: bind $foo, 10, '=X', red)").not.markupToError();
expect("(meter: bind $foo, 10, '=X', (gradient:90,0,red,1,white))").not.markupToError();
expect("(meter: bind $foo, 10, '=X', 'Yo', red)").not.markupToError();
expect("(meter: bind $foo, -1, '=X', 'Yo', red)").markupToError();
});
it("creates a <tw-meter> with a background-size relative to the bound variable's value", function() {
var p = runPassage("(set:$foo to 10)(meter: bind $foo, 100, '=X', 'Yo')");
expect(p.find('tw-meter').css('background-size')).toBe('10%');
p = runPassage("(set:$foo to 10)(meter: bind $foo, 200, '=X', 'Yo')");
expect(p.find('tw-meter').css('background-size')).toBe('5%');
});
it("uses the given gradient or colour for the bar, ignoring the angle", function() {
var p = runPassage("(set:$foo to 100)(meter: bind $foo, 100, '=X', (gradient:45,0,#ab1212,1,#ac6060))");
expect(p.find('tw-meter')).toHaveBackgroundGradient(270, [
{stop:0,colour:"#AB1212"},
{stop:1,colour:"#AC6060"},
]);
p = runPassage("(set:$foo to 100)(meter: bind $foo, 100, 'X=', (gradient:45,0,white,1,black))");
expect(p.find('tw-meter')).toHaveBackgroundGradient(90, [
{stop:0,colour:"#FFFFFF"},
{stop:1,colour:"#000000"},
]);
p = runPassage("(set:$foo to 100)(meter: bind $foo, 100, 'X=', #FADABC)");
expect(p.find('tw-meter')).toHaveBackgroundGradient(90, [
{stop:0,colour:"#FADABC"},
{stop:1,colour:"#FADABC"},
]);
});
it("scales the gradient based on how much of the bar is remaining", function() {
var p = runPassage("(set:$foo to 50)(meter: bind $foo, 100, '=X', (gradient:45,0,#ab1212,1,#ac6060))");
expect(p.find('tw-meter')).toHaveBackgroundGradient(270, [
{stop:0,colour:"#AB1212"},
{stop:2,colour:"#AC6060"},
]);
});
/*
TODO: center meter tests
*/
it("updates the bar when the variable updates", function() {
var p = runPassage("(set:$foo to 10)(meter: bind $foo, 100, '=X', 'Yo')(link:'baz')[(set:$foo to 90)]");
expect(p.find('tw-meter').css('background-size')).toBe('10%');
p.find('tw-link').click();
expect(p.find('tw-meter').css('background-size')).toBe('90%');
});
it("updates the text when the variable updates", function() {
var p = runPassage("(set:$foo to 10)(meter: bind $foo, 100, '=X', 'FOO: $foo')(link:'baz')[(set:$foo to 90)]");
expect(p.find('tw-meter').text()).toBe('FOO: 10');
p.find('tw-link').click();
expect(p.find('tw-meter').text()).toBe('FOO: 90');
});
it("gives the <tw-expression> the specified margins, width, and rows (if given), as well as display:block", function() {
[
['=XX=', 25, 50],
['X===', 0, 25],
['X===', 0, 25],
['==XXXXXXXX', 20, 80],
].forEach(function(a) {
var code = a[0], marginLeft=a[1], width=a[2];
var t = runPassage("(meter: bind $foo, 7, '" + code + "')").find('tw-expression[name=meter]');
var s = t.attr('style');
expect(s).toMatch(RegExp("margin-left:\\s*"+marginLeft+"%"));
expect(s).toMatch(RegExp("\\bwidth:\\s*"+width+"%"));
expect(s).toMatch(/display:\s*block/);
});
});
it("aligns the text with the meter's alignment", function() {
var p = runPassage("(set:$foo to 10)(meter: bind $foo, 100, '==X', 'Yo')");
expect(p.find('tw-meter').css('text-align')).toBe('right');
p = runPassage("(meter: bind $foo, 100, '=X==', 'Yo')");
expect(p.find('tw-meter').css('text-align')).toBe('center');
});
});
});
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment