Skip to content

Commit 88c7e30

Browse files
committed
Implement a HSV-based colour picker in JavaScript
1 parent d2b3639 commit 88c7e30

File tree

4 files changed

+258
-5
lines changed

4 files changed

+258
-5
lines changed

javascript/components/text_draw/rectangle.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,18 @@ import { TextDraw } from 'components/text_draw/text_draw.js';
1111
// after a fair amount of trail and error, but it seems to work reliably despite the documentation
1212
// pointing out that these are entirely the wrong values.
1313
export class Rectangle extends TextDraw {
14-
constructor(x, y, width, height, color) {
14+
constructor(x, y, width, height, color, selectable = null) {
1515
super({
16-
position: [x, y],
17-
textSize: [x + width, 1],
18-
letterSize: [1, Math.pow((height - 3) / 10, 1.0122)],
16+
position: [ x, y ],
17+
textSize: [ x + width, 1 ],
18+
letterSize: [ 1, Math.pow((height - 3) / 10, 1.0122) ],
1919
alignment: TextDraw.ALIGN_LEFT,
2020

2121
useBox: true,
2222
boxColor: color,
23-
text: '_'
23+
text: '_',
24+
25+
selectable
2426
});
2527
}
2628
};
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright 2020 Las Venturas Playground. All rights reserved.
2+
// Use of this source code is governed by the MIT license, a copy of which can
3+
// be found in the LICENSE file.
4+
5+
import { Color } from 'base/color.js';
6+
7+
import { displayColorPicker } from 'features/player_colors/color_picker_ui.js';
8+
import { range } from 'base/range.js';
9+
10+
// Titles that will be shown on the color picker, to tell the player which phase they're in.
11+
const kSelectBaseTitle = '(1/2) Select base color...';
12+
const kSelectShadeTitle = '(2/2) Select the color shade...';
13+
14+
// This class represents the two-step colour picker supported by Las Venturas Playground. The first
15+
// step asks the player for the hue of the color, where the second step asks them for the saturation
16+
// and value of the color, in six different variants.
17+
export class ColorPicker {
18+
// Displays the color picker for the given |player|. This will start the two-phase flow, and
19+
// return either a Color instance when selected, or null when aborted.
20+
static async displayForPlayer(player) {
21+
// (1) Have the |player| select the base color from which they will pick a shade.
22+
const baseColor =
23+
await displayColorPicker(player, kSelectBaseTitle, ColorPicker.getColorFamilies());
24+
25+
// Bail out if the |player| did not select a base color.
26+
if (!baseColor)
27+
return null;
28+
29+
// (2) Have the |player| pick a shade within the |baseColor|.
30+
return await displayColorPicker(
31+
player, kSelectShadeTitle, ColorPicker.getColorFamilyValues(baseColor));
32+
}
33+
34+
// Returns the 36 color families that are to be displayed as the first step of the picker. They
35+
// evenly represent the hue part of the HSV color spectrum.
36+
static getColorFamilies() {
37+
return range(36).map(index => Color.fromHsv((3 + index * 10) / 360, 1, 1));
38+
}
39+
40+
// Returns the 32 color values that are to be displayed as the second step in the picker, based
41+
// on the |baseColor| that has been chosen by the player so far.
42+
static getColorFamilyValues(baseColor) {
43+
const [ hue ] = baseColor.toHsv();
44+
45+
const saturationSteps = [ 1.0, 0.85, 0.7, 0.5, 0.3, 0.1 ];
46+
const valueSteps = [ 1.0, 0.9, 0.8, 0.65, 0.5, 0.35 ];
47+
48+
const colors = [];
49+
50+
for (const value of valueSteps) {
51+
for (const saturation of saturationSteps)
52+
colors.push(Color.fromHsv(hue, saturation, value));
53+
}
54+
55+
return colors;
56+
}
57+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright 2020 Las Venturas Playground. All rights reserved.
2+
// Use of this source code is governed by the MIT license, a copy of which can
3+
// be found in the LICENSE file.
4+
5+
import { Color } from 'base/color.js';
6+
import { Rectangle } from 'components/text_draw/rectangle.js';
7+
import { TextDraw } from 'components/text_draw/text_draw.js';
8+
9+
// Highlight colour to display on the cancel button when hovering over it.
10+
const kCancelButtonHighlightColor = Color.fromRGBA(0xB0, 0xBE, 0xC5, 0xFF);
11+
12+
// Height and width of the individual color rectangles.
13+
const kColorRectangleMarginX = 9;
14+
const kColorRectangleMarginY = 0.5;
15+
16+
const kColorRectangleHeight = 20.0;
17+
const kColorRectangleWidth = 16.0;
18+
19+
// Height to reserve for the picker's header, indicating the phase of selection.
20+
const kHeaderHeight = 13.0;
21+
22+
// The background color for the color picker, a dark, slightly transparent black.
23+
const kPickerBackgroundColor = Color.fromRGBA(0, 0, 0, 0x85);
24+
25+
// Offset of the picker itself on the player screens. Based on GTA's canonical screen resolution.
26+
const kPickerOffsetX = 32.0;
27+
const kPickerOffsetY = 154.0;
28+
29+
// Compound values of the above definitions, to avoid having complicated calculations inline.
30+
const kPickerWidth = 7 * kColorRectangleMarginX + 6 * kColorRectangleWidth;
31+
const kPickerHeight = 7 * kColorRectangleMarginY + 6 * kColorRectangleHeight + kHeaderHeight + 15;
32+
33+
// After how many seconds do we automatically time out the colour picker?
34+
const kPickerTimeoutMs = 5 * 1000;
35+
36+
// Displays a color picker for the |player| with the given |colors|, which must be an array with 36
37+
// instances of the Color object. Will return a Color instance when selected, or NULL when aborted.
38+
export async function displayColorPicker(player, title, colors) {
39+
let resolver = null;
40+
41+
const promise = new Promise(resolve => resolver = resolve);
42+
const elements = [
43+
createBackgroundElement(),
44+
createTitleElement(title),
45+
createCancelButton(),
46+
createCancelButtonLabel(resolver.bind(/* thisArg= */ null, /* color= */ null)),
47+
];
48+
49+
// Create elements for all the |colors| that can be selected by the player.
50+
for (const color of colors) {
51+
elements.push(createColorElement({
52+
index: elements.length - 4,
53+
color: color,
54+
55+
// Invoke the |resolver| with the given |color| when clicked on by the |player|.
56+
listener: resolver.bind(/* thisArg= */ null, color),
57+
}));
58+
}
59+
60+
// (1) Display the color picker to the |player|.
61+
for (const element of elements)
62+
element.displayForPlayer(player);
63+
64+
// (2) Schedule a timer for |kPickerTimeoutMs| to automatically time out the picker, if needed.
65+
wait(kPickerTimeoutMs).then(() => {
66+
if (resolver)
67+
resolver(/* color= */ null);
68+
});
69+
70+
// (3) Start selecting for the |player| and wait for the picker to complete, either by timeout,
71+
// cancellation or selection. We clean up our state immediately after.
72+
pawnInvoke('SelectTextDraw', 'ii', player.id, kCancelButtonHighlightColor.toNumberRGBA());
73+
74+
const color = await promise;
75+
76+
pawnInvoke('CancelSelectTextDraw', 'i', player.id);
77+
78+
resolver = null; // avoid double-resolving
79+
80+
// (4) Remove all the |elements| for the player, as the folow has completed
81+
for (const element of elements)
82+
element.hideForPlayer(player);
83+
84+
// (5) And return the selected |color| (which may be NULL) to the caller.
85+
return color;
86+
}
87+
88+
// Creates an element for the picker's background for the |player|. An adjustment will be applied in
89+
// the element's height as something's wrong with the Rectangle calculation.
90+
function createBackgroundElement() {
91+
return new Rectangle(
92+
/* x= */ kPickerOffsetX,
93+
/* y= */ kPickerOffsetY,
94+
/* width= */ kPickerWidth,
95+
/* height= */ kPickerHeight,
96+
/* color= */ kPickerBackgroundColor);
97+
}
98+
99+
// Creates an element to represent the picker's title, which helps the player understand where in
100+
// the color selection flow they are. Real complicated with two steps.
101+
function createTitleElement(title) {
102+
return new TextDraw({
103+
text: title,
104+
position: [
105+
kPickerOffsetX + 0.666 * kColorRectangleMarginX,
106+
kPickerOffsetY + 1,
107+
],
108+
109+
font: TextDraw.FONT_SANS_SERIF,
110+
letterSize: [ 0.27, 0.90 ],
111+
shadowSize: 0,
112+
});
113+
}
114+
115+
// Creates a cancel button that will invoke the |listener| once clicked upon. This helps players
116+
// when they change their minds, and actually don't want to change colors at all.
117+
function createCancelButton() {
118+
return new Rectangle(
119+
/* x= */ kPickerOffsetX,
120+
/* y= */ kPickerOffsetY + kPickerHeight - /* arbitrary? */ 9.0,
121+
/* width= */ kPickerWidth,
122+
/* height= */ kColorRectangleHeight,
123+
/* color= */ kPickerBackgroundColor);
124+
}
125+
126+
// Creates the text label to draw on the cancel button.
127+
function createCancelButtonLabel(listener) {
128+
return new ClickableTextDraw(listener, {
129+
text: 'CANCEL',
130+
position: [
131+
kPickerOffsetX + kPickerWidth / 2,
132+
kPickerOffsetY + kPickerHeight - /* arbitrary? */ 5.5,
133+
],
134+
135+
alignment: TextDraw.ALIGN_CENTER,
136+
font: TextDraw.FONT_SANS_SERIF,
137+
letterSize: [ 0.39, 0.90 ],
138+
shadowSize: 0,
139+
selectable: true,
140+
});
141+
}
142+
143+
// Creates an element to represent the given |color|, at the given |index| on the 6x6 grid. The
144+
// |listener| will be called when the color has been selected.
145+
function createColorElement({ index, color, listener } = {}) {
146+
const elementRow = Math.floor(index / 6);
147+
const elementColumn = index % 6;
148+
149+
const elementOffsetX =
150+
kPickerOffsetX + kColorRectangleMarginX +
151+
elementColumn * (kColorRectangleMarginX + kColorRectangleWidth);
152+
153+
const elementOffsetY =
154+
kPickerOffsetY + kColorRectangleMarginY + kHeaderHeight +
155+
elementRow * (kColorRectangleMarginY + kColorRectangleHeight);
156+
157+
return new ClickableTextDraw(listener, {
158+
position: [ elementOffsetX + kColorRectangleWidth / 2, elementOffsetY ],
159+
text: '_',
160+
161+
alignment: TextDraw.ALIGN_CENTER,
162+
letterSize: [ 0.0, 1.775 ],
163+
textSize: [ kColorRectangleWidth, kColorRectangleHeight ],
164+
selectable: true,
165+
166+
boxColor: color,
167+
useBox: true,
168+
});
169+
}
170+
171+
// Implementation of the TextDraw class that listens to click events from the player.
172+
class ClickableTextDraw extends TextDraw {
173+
#listener_ = null;
174+
175+
constructor(listener, ...params) {
176+
super(...params);
177+
178+
this.#listener_ = listener;
179+
}
180+
181+
// Called when the |player| has clicked on this text draw. Invokes the listener.
182+
onClick(player) { this.#listener_.call(null); }
183+
}

javascript/features/player_colors/player_colors.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Use of this source code is governed by the MIT license, a copy of which can
33
// be found in the LICENSE file.
44

5+
import { ColorPicker } from 'features/player_colors/color_picker.js';
56
import { Feature } from 'components/feature_manager/feature.js';
67
import { PlayerColorsManager } from 'features/player_colors/player_colors_manager.js';
78
import { PlayerColorsSupplement } from 'features/player_colors/player_colors_supplement.js';
@@ -28,6 +29,16 @@ export default class PlayerColors extends Feature {
2829
this.manager_.initialize();
2930
}
3031

32+
// ---------------------------------------------------------------------------------------------
33+
// API of the PlayerColors feature
34+
// ---------------------------------------------------------------------------------------------
35+
36+
// Takes the |player| through the color picker flow by displaying the picker to them. The caller
37+
// of this function is expected to do the necessary Limits tests.
38+
async displayColorPickerForPlayer(player) { return await ColorPicker.displayForPlayer(player); }
39+
40+
// ---------------------------------------------------------------------------------------------
41+
3142
dispose() {
3243
Player.provideSupplement('colors', null);
3344

0 commit comments

Comments
 (0)