Skip to content
Permalink
Browse files
Implement a HSV-based colour picker in JavaScript
  • Loading branch information
RussellLVP committed Jul 21, 2020
1 parent d2b3639 commit 88c7e30b905a127680a59f7a38da6970d8e3e662
Showing 4 changed files with 258 additions and 5 deletions.
@@ -11,16 +11,18 @@ import { TextDraw } from 'components/text_draw/text_draw.js';
// after a fair amount of trail and error, but it seems to work reliably despite the documentation
// pointing out that these are entirely the wrong values.
export class Rectangle extends TextDraw {
constructor(x, y, width, height, color) {
constructor(x, y, width, height, color, selectable = null) {
super({
position: [x, y],
textSize: [x + width, 1],
letterSize: [1, Math.pow((height - 3) / 10, 1.0122)],
position: [ x, y ],
textSize: [ x + width, 1 ],
letterSize: [ 1, Math.pow((height - 3) / 10, 1.0122) ],
alignment: TextDraw.ALIGN_LEFT,

useBox: true,
boxColor: color,
text: '_'
text: '_',

selectable
});
}
};
@@ -0,0 +1,57 @@
// Copyright 2020 Las Venturas Playground. All rights reserved.
// Use of this source code is governed by the MIT license, a copy of which can
// be found in the LICENSE file.

import { Color } from 'base/color.js';

import { displayColorPicker } from 'features/player_colors/color_picker_ui.js';
import { range } from 'base/range.js';

// Titles that will be shown on the color picker, to tell the player which phase they're in.
const kSelectBaseTitle = '(1/2) Select base color...';
const kSelectShadeTitle = '(2/2) Select the color shade...';

// This class represents the two-step colour picker supported by Las Venturas Playground. The first
// step asks the player for the hue of the color, where the second step asks them for the saturation
// and value of the color, in six different variants.
export class ColorPicker {
// Displays the color picker for the given |player|. This will start the two-phase flow, and
// return either a Color instance when selected, or null when aborted.
static async displayForPlayer(player) {
// (1) Have the |player| select the base color from which they will pick a shade.
const baseColor =
await displayColorPicker(player, kSelectBaseTitle, ColorPicker.getColorFamilies());

// Bail out if the |player| did not select a base color.
if (!baseColor)
return null;

// (2) Have the |player| pick a shade within the |baseColor|.
return await displayColorPicker(
player, kSelectShadeTitle, ColorPicker.getColorFamilyValues(baseColor));
}

// Returns the 36 color families that are to be displayed as the first step of the picker. They
// evenly represent the hue part of the HSV color spectrum.
static getColorFamilies() {
return range(36).map(index => Color.fromHsv((3 + index * 10) / 360, 1, 1));
}

// Returns the 32 color values that are to be displayed as the second step in the picker, based
// on the |baseColor| that has been chosen by the player so far.
static getColorFamilyValues(baseColor) {
const [ hue ] = baseColor.toHsv();

const saturationSteps = [ 1.0, 0.85, 0.7, 0.5, 0.3, 0.1 ];
const valueSteps = [ 1.0, 0.9, 0.8, 0.65, 0.5, 0.35 ];

const colors = [];

for (const value of valueSteps) {
for (const saturation of saturationSteps)
colors.push(Color.fromHsv(hue, saturation, value));
}

return colors;
}
}
@@ -0,0 +1,183 @@
// Copyright 2020 Las Venturas Playground. All rights reserved.
// Use of this source code is governed by the MIT license, a copy of which can
// be found in the LICENSE file.

import { Color } from 'base/color.js';
import { Rectangle } from 'components/text_draw/rectangle.js';
import { TextDraw } from 'components/text_draw/text_draw.js';

// Highlight colour to display on the cancel button when hovering over it.
const kCancelButtonHighlightColor = Color.fromRGBA(0xB0, 0xBE, 0xC5, 0xFF);

// Height and width of the individual color rectangles.
const kColorRectangleMarginX = 9;
const kColorRectangleMarginY = 0.5;

const kColorRectangleHeight = 20.0;
const kColorRectangleWidth = 16.0;

// Height to reserve for the picker's header, indicating the phase of selection.
const kHeaderHeight = 13.0;

// The background color for the color picker, a dark, slightly transparent black.
const kPickerBackgroundColor = Color.fromRGBA(0, 0, 0, 0x85);

// Offset of the picker itself on the player screens. Based on GTA's canonical screen resolution.
const kPickerOffsetX = 32.0;
const kPickerOffsetY = 154.0;

// Compound values of the above definitions, to avoid having complicated calculations inline.
const kPickerWidth = 7 * kColorRectangleMarginX + 6 * kColorRectangleWidth;
const kPickerHeight = 7 * kColorRectangleMarginY + 6 * kColorRectangleHeight + kHeaderHeight + 15;

// After how many seconds do we automatically time out the colour picker?
const kPickerTimeoutMs = 5 * 1000;

// Displays a color picker for the |player| with the given |colors|, which must be an array with 36
// instances of the Color object. Will return a Color instance when selected, or NULL when aborted.
export async function displayColorPicker(player, title, colors) {
let resolver = null;

const promise = new Promise(resolve => resolver = resolve);
const elements = [
createBackgroundElement(),
createTitleElement(title),
createCancelButton(),
createCancelButtonLabel(resolver.bind(/* thisArg= */ null, /* color= */ null)),
];

// Create elements for all the |colors| that can be selected by the player.
for (const color of colors) {
elements.push(createColorElement({
index: elements.length - 4,
color: color,

// Invoke the |resolver| with the given |color| when clicked on by the |player|.
listener: resolver.bind(/* thisArg= */ null, color),
}));
}

// (1) Display the color picker to the |player|.
for (const element of elements)
element.displayForPlayer(player);

// (2) Schedule a timer for |kPickerTimeoutMs| to automatically time out the picker, if needed.
wait(kPickerTimeoutMs).then(() => {
if (resolver)
resolver(/* color= */ null);
});

// (3) Start selecting for the |player| and wait for the picker to complete, either by timeout,
// cancellation or selection. We clean up our state immediately after.
pawnInvoke('SelectTextDraw', 'ii', player.id, kCancelButtonHighlightColor.toNumberRGBA());

const color = await promise;

pawnInvoke('CancelSelectTextDraw', 'i', player.id);

resolver = null; // avoid double-resolving

// (4) Remove all the |elements| for the player, as the folow has completed
for (const element of elements)
element.hideForPlayer(player);

// (5) And return the selected |color| (which may be NULL) to the caller.
return color;
}

// Creates an element for the picker's background for the |player|. An adjustment will be applied in
// the element's height as something's wrong with the Rectangle calculation.
function createBackgroundElement() {
return new Rectangle(
/* x= */ kPickerOffsetX,
/* y= */ kPickerOffsetY,
/* width= */ kPickerWidth,
/* height= */ kPickerHeight,
/* color= */ kPickerBackgroundColor);
}

// Creates an element to represent the picker's title, which helps the player understand where in
// the color selection flow they are. Real complicated with two steps.
function createTitleElement(title) {
return new TextDraw({
text: title,
position: [
kPickerOffsetX + 0.666 * kColorRectangleMarginX,
kPickerOffsetY + 1,
],

font: TextDraw.FONT_SANS_SERIF,
letterSize: [ 0.27, 0.90 ],
shadowSize: 0,
});
}

// Creates a cancel button that will invoke the |listener| once clicked upon. This helps players
// when they change their minds, and actually don't want to change colors at all.
function createCancelButton() {
return new Rectangle(
/* x= */ kPickerOffsetX,
/* y= */ kPickerOffsetY + kPickerHeight - /* arbitrary? */ 9.0,
/* width= */ kPickerWidth,
/* height= */ kColorRectangleHeight,
/* color= */ kPickerBackgroundColor);
}

// Creates the text label to draw on the cancel button.
function createCancelButtonLabel(listener) {
return new ClickableTextDraw(listener, {
text: 'CANCEL',
position: [
kPickerOffsetX + kPickerWidth / 2,
kPickerOffsetY + kPickerHeight - /* arbitrary? */ 5.5,
],

alignment: TextDraw.ALIGN_CENTER,
font: TextDraw.FONT_SANS_SERIF,
letterSize: [ 0.39, 0.90 ],
shadowSize: 0,
selectable: true,
});
}

// Creates an element to represent the given |color|, at the given |index| on the 6x6 grid. The
// |listener| will be called when the color has been selected.
function createColorElement({ index, color, listener } = {}) {
const elementRow = Math.floor(index / 6);
const elementColumn = index % 6;

const elementOffsetX =
kPickerOffsetX + kColorRectangleMarginX +
elementColumn * (kColorRectangleMarginX + kColorRectangleWidth);

const elementOffsetY =
kPickerOffsetY + kColorRectangleMarginY + kHeaderHeight +
elementRow * (kColorRectangleMarginY + kColorRectangleHeight);

return new ClickableTextDraw(listener, {
position: [ elementOffsetX + kColorRectangleWidth / 2, elementOffsetY ],
text: '_',

alignment: TextDraw.ALIGN_CENTER,
letterSize: [ 0.0, 1.775 ],
textSize: [ kColorRectangleWidth, kColorRectangleHeight ],
selectable: true,

boxColor: color,
useBox: true,
});
}

// Implementation of the TextDraw class that listens to click events from the player.
class ClickableTextDraw extends TextDraw {
#listener_ = null;

constructor(listener, ...params) {
super(...params);

this.#listener_ = listener;
}

// Called when the |player| has clicked on this text draw. Invokes the listener.
onClick(player) { this.#listener_.call(null); }
}
@@ -2,6 +2,7 @@
// Use of this source code is governed by the MIT license, a copy of which can
// be found in the LICENSE file.

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

// ---------------------------------------------------------------------------------------------
// API of the PlayerColors feature
// ---------------------------------------------------------------------------------------------

// Takes the |player| through the color picker flow by displaying the picker to them. The caller
// of this function is expected to do the necessary Limits tests.
async displayColorPickerForPlayer(player) { return await ColorPicker.displayForPlayer(player); }

// ---------------------------------------------------------------------------------------------

dispose() {
Player.provideSupplement('colors', null);

0 comments on commit 88c7e30

Please sign in to comment.